PortalGun Router

Results

Measured on one laptop, a 16-vCPU WSL2 VM, with CPU limits on every container (except Redis and Postgres in the registry run) and the proxy comparison on separate CPU sets. At least three repetitions per cell, medians shown unless a table says otherwise. A laptop is noisier than a server, so small differences here mean little. The dimensions are containers behind fault injection: the failures and latencies are real, the geography is not.

What a proxy in the path costs

GET with a 1 KB response, open-loop load, every proxy pinned to one CPU with 256 MB. Added latency is the median over repetitions minus the same request with no proxy at all. CPU per request is the proxy container's own, at 4k rps, the highest rate every proxy held. Max is the highest offered rate at which most repetitions kept p99 under 50 ms, failures under 0.1% and throughput at 95% of the offered rate. Run 20261008-163533.

proxy+p50 at 1k+p99 at 1kCPU/request at 4kmax rpsmemory at max
Go httputil.ReverseProxy+139 µs+988 µs146 µs8k150 MB
PortalGun+155 µs+1.1 ms153 µs6k91 MB
nginx+107 µs+691 µs115 µs15k38 MB
HAProxy+108 µs+801 µs107 µs12.5k90 MB
Caddy+201 µs+1.7 ms171 µs4k103 MB
Envoy+207 µs+1.3 ms180 µs6k139 MB

Past their maximum, the Go proxies and Envoy keep accepting work: requests queue in memory until the container reaches 256 MB and starts swapping, and throughput falls to a few hundred rps, or the kernel kills it. nginx and HAProxy stay far below the limit. PortalGun ran here without its cap on what it holds in total, so it fails the same way. The cap is measured below. The lab itself tops out near 20k rps with no proxy at all, so the fastest maximums are partly the lab's.

Near the knee, on a quieter night

The same test again at 6k, 7k, 8k rps, with everything else on the laptop stopped. Every proxy held every rate in every repetition, so the gap in the max column above says more about the noisier evening of the sweep than about the proxies. What stays is the tail. Run 20261009-210322.

rateproxyp50p99CPU/request
6kGo httputil.ReverseProxy400 µs2.4 ms119 µs
6kPortalGun417 µs3.9 ms122 µs
7kGo httputil.ReverseProxy416 µs3.2 ms108 µs
7kPortalGun471 µs5.5 ms112 µs
8kGo httputil.ReverseProxy443 µs5.4 ms97 µs
8kPortalGun490 µs12 ms102 µs

Past the knee

Each point starts from a freshly restarted proxy and goes straight to the offered rate. Each cell shows the 2xx responses served per second, the p99 of those responses only, and the highest memory any repetition reached. Anything above the cap gets a 503 at once and isn't counted as served. A run counts as collapsed when more than 1% of its requests time out or never get sent. PortalGun's default cap is 256, the largest that never collapsed here. Run 20261010-133903.

offered8k10k12k15k20k
Go httputil.ReverseProxy8k
p99 17 ms · 256 MB
1 of 3 collapsed
collapsed
256 MB + swap
collapsed
256 MB + swap
collapsed
256 MB + swap
collapsed
256 MB + swap
PortalGun, no capcollapsed
256 MB + swap
collapsed
256 MB + swap
collapsed
256 MB + swap
collapsed
256 MB + swap
collapsed
256 MB + swap
PortalGun, cap 10248k
p99 15 ms · 127 MB
10k
p99 27 ms · 136 MB
10.5k
p99 254 ms · 256 MB
1 of 3 collapsed
9.2k
p99 393 ms · 256 MB
1 of 3 collapsed
collapsed
256 MB + swap
PortalGun, cap 5128k
p99 9.7 ms · 84 MB
10k
p99 20 ms · 90 MB
11.5k
p99 114 ms · 107 MB
10.3k
p99 145 ms · 152 MB
collapsed
258 MB + swap
PortalGun, cap 2568k
p99 7.5 ms · 62 MB
10k
p99 10 ms · 70 MB
12.0k
p99 20 ms · 74 MB
10.1k
p99 78 ms · 103 MB
7.9k
p99 94 ms · 133 MB
PortalGun, cap 1288k
p99 5.4 ms · 54 MB
10k
p99 7.9 ms · 59 MB
12.0k
p99 12 ms · 60 MB
12.1k
p99 37 ms · 70 MB
8.4k
p99 77 ms · 101 MB

One limit across two gateways

1,000 requests per second allowed for one key, split over two gateways that share Redis. Added latency and Redis trips are measured at 800 rps, under the limit, against the same load with no limiter. Differences under about 0.1 ms are noise on this laptop. Admitted is measured at 1,500 rps and compares what got through with a perfect single bucket replayed over the same requests: plus means over the limit, minus means good requests turned away. Shifting sends 90% of the traffic to one gateway and swaps every 5 s, and burst is 800 rps with spikes at 5,000, also shifting. Without Redis counts the decisions a gateway made on its own at 1,500 rps and an even split, out of about 52,000 per run. Run 20261009-150154.

design+p50+p99Redis trips/requestadmitted, evenadmitted, shiftingadmitted, burstwithout Redis
Static split, no coordination+10 µs+199 µs0.000%-34.4%-27.5%-
Redis on every request+379 µs+1.5 ms1.00+9.4%-9%-11.4%15,183
Local bucket, sync every 10 ms+11 µs+3 µs0.250%0%-0.1%0
Local bucket, sync every 50 ms+10 µs+79 µs0.050%0%-1%0
Local bucket, sync every 100 ms+1 µs+46 µs0.03+0.1%-0.2%-1.3%0
Local bucket, sync every 500 ms+3 µs+122 µs0.01-0.3%-19.1%-14.3%0
Token lease, 5 ms+154 µs+597 µs0.430%0%-0.1%1
Token lease, 20 ms+19 µs+339 µs0.130%0%-0.5%0
Token lease, 100 ms+8 µs+210 µs0.03-0.1%-0.1%-2.5%0

In this run, Redis on every request and the token leases still had their first breaker: after three calls in a row missed their 2 ms deadline they stopped asking Redis for a second and decided on their own share of the limit. Here that happened often enough to undo the exact design: a gateway deciding alone stops spending from the shared bucket, so the other one gets all of it, and nothing was charged back afterwards. The local buckets only talk to Redis between requests and never had to. The rebuilt breaker is measured below.

The Redis breaker, before and after

The rebuilt breaker opens only when at least half of the calls in the last 100 ms failed, and at least five, stays open for 100 ms, then lets one call through. What a gateway admitted on its own is charged back to the shared bucket once Redis answers again, unless the bucket expired meanwhile. Measured against the code before it on one evening, alternating, three runs each, at 1,500 rps. The 10,000 keys row is 3,000 rps over keys drawn from a Zipf distribution. Without Redis counts the decisions made on the static split at an even split, out of about 52,000. Runs before/20261012-merged and after/20261012-merged.

designadmitted, evenadmitted, shiftingadmitted, 4 gateways, shiftingadmitted, 10,000 keyswithout Redis
Redis on every request+11.6% → -0.2%-13.1% → -1.8%-23.3% → -2.5%+9.8% → +0.4%20,787 → 3,132
Token lease, 5 ms+1.2% → 0%0% → -0.1%0% → 0%+9.4% → -0.1%792 → 5
Token lease, 20 ms0% → 0%-0.1% → 0%0% → 0%+8.2% → 0%1 → 1

When Redis is slow or gone

1,500 rps with Redis 5 ms slower for the whole run, or stopped. Degraded counts the decisions a gateway made on its own because Redis did not answer in time.

designp50, Redis slowadmitted, Redis downdegraded, Redis down
Redis on every request351 µs0%52,419
Local bucket, sync every 100 ms345 µs0%52,284
Token lease, 20 ms353 µs0%52,419

When a dimension breaks

2,000 rps spread over three dimensions, one of them broken for 30 s. Each cell is the median number of requests that failed in those 30 s, and how long until less than 1% of the traffic still touched the broken one (100 ms bins). Probes are active health checks, passive means ejecting on observed errors, retries go to another dimension. Run 20261009-175756.

faultProbesPassiveBothBoth + retries
Crash1,560 1500 ms10 0 ms6 0 ms0 0 ms
Connection reset1,226 2000 ms11 0 ms7 0 ms0 0 ms
Blackhole18 0 ms16 0 ms11 0 ms0 0 ms
500 ms slower0 0 ms0 0 ms0 0 ms0 0 ms
30% errors6,009 never185 1500 ms204 5900 ms0 500 ms
Flapping every 2 s3,961 never13 15900 ms11 15900 ms0 15900 ms

One setting at a time

Probes, passive ejection and retries on, everything else at the default except the one thing named.

caselostlost 10 s afterp99 duringmoved
Blackhole, 100 ms per try005.9 ms0 ms
Blackhole, 250 ms per try005.8 ms0 ms
Blackhole, 1 s per try (default)005.8 ms0 ms
Crash, default005.9 ms0 ms
Crash, eject on the first refused connection005.8 ms0 ms
Crash, back at full weight without slow start005.9 ms0 ms
500 ms slower, round robin00554 ms1600 ms
500 ms slower, least outstanding006.0 ms0 ms
500 ms slower, P2C with peak EWMA (default)005.9 ms0 ms
20% errors everywhere, no panic mode35,52712,7987.5 msnever
20% errors everywhere, panic below 50% healthy2,507011 msnever

Ten minutes with nothing broken but 1% errors and log-normal delays everywhere: 0 false ejections (median of 3 runs, at most 0).

Canary decisions, simulated

72 cells, 2,000 trials for every cell where the canary is as good as the stable version and 500 for every regression, five-minute steps, the same random traffic for every rule within a trial. Run sim-20261005-080554.

How often a good version gets rolled back

Range over the 36 cells: base error 0.1 to 2%, canary share 1 to 25%, 200 and 2,000 rps, with and without a 30 s outage downstream that hits both versions.

Sequential + terminal test what the controller runs0.1% to 0.7%
Sequential test alone promotes undecided steps0.1% to 0.7%
Fixed horizon one exact test at the end of the step0.4% to 1.5%
z-test every second nominally 5%22.3% to 51.3%
Canary over 2x stable trailing 60 s1.6% to 90.6%
Canary over a fixed rate 2x the known normal rate0.2% to 100%

How fast a bad version goes, and how often it is caught

Median seconds to roll back, share of trials rolled back in brackets. The fixed-horizon and threshold rules look at errors only, so their numbers on the latency rows are the false rollbacks they would have made anyway.

canarySequential + terminal testFixed horizonCanary over 2x stable
1.2x errors, 0.1% base, 200 rps, 5% canary300 s (0.8%)300 s (1.4%)69 s (93%)
1.5x errors, 0.1% base, 200 rps, 5% canary300 s (1.8%)300 s (6.8%)48 s (96.2%)
2x errors, 0.1% base, 200 rps, 5% canary300 s (8%)300 s (17.2%)37 s (99.8%)
5x errors, 0.1% base, 200 rps, 5% canary250 s (93%)300 s (95.8%)15 s (100%)
fails every request, 0.1% base, 200 rps, 5% canary1 s (100%)300 s (100%)10 s (100%)
1.2x errors, 0.1% base, 2000 rps, 5% canary270 s (4.4%)300 s (11.2%)7 s (80.4%)
1.5x errors, 0.1% base, 2000 rps, 5% canary254 s (34.8%)300 s (52.8%)5 s (95.8%)
2x errors, 0.1% base, 2000 rps, 5% canary147 s (91.8%)300 s (97%)4 s (99.8%)
5x errors, 0.1% base, 2000 rps, 5% canary27 s (100%)300 s (100%)2 s (100%)
fails every request, 0.1% base, 2000 rps, 5% canary1 s (100%)300 s (100%)1 s (100%)
1.2x errors, 0.5% base, 200 rps, 5% canary300 s (2.2%)300 s (3.6%)22 s (88.2%)
1.5x errors, 0.5% base, 200 rps, 5% canary260 s (14.6%)300 s (26.8%)15 s (96.4%)
2x errors, 0.5% base, 200 rps, 5% canary220 s (61.2%)300 s (80%)14 s (99.8%)
5x errors, 0.5% base, 200 rps, 5% canary53 s (100%)300 s (100%)11 s (100%)
fails every request, 0.5% base, 200 rps, 5% canary1 s (100%)300 s (100%)10 s (100%)
1.2x errors, 0.5% base, 2000 rps, 5% canary54 s (4.4%)300 s (46.8%)2 s (39.2%)
1.5x errors, 0.5% base, 2000 rps, 5% canary63 s (45%)300 s (100%)3 s (74.2%)
2x errors, 0.5% base, 2000 rps, 5% canary30 s (95.6%)300 s (100%)3 s (100%)
5x errors, 0.5% base, 2000 rps, 5% canary6 s (100%)300 s (100%)2 s (100%)
fails every request, 0.5% base, 2000 rps, 5% canary1 s (100%)300 s (100%)1 s (100%)
1.2x errors, 2% base, 200 rps, 5% canary300 s (4.4%)300 s (15.6%)15 s (44.4%)
1.5x errors, 2% base, 200 rps, 5% canary164 s (48.8%)300 s (84.6%)19 s (82%)
2x errors, 2% base, 200 rps, 5% canary72 s (96.4%)300 s (99.8%)12 s (99.8%)
5x errors, 2% base, 200 rps, 5% canary14 s (100%)300 s (100%)10 s (100%)
fails every request, 2% base, 200 rps, 5% canary1 s (100%)300 s (100%)10 s (100%)
1.2x errors, 2% base, 2000 rps, 5% canary15 s (3.8%)300 s (97.8%)2 s (15.6%)
1.5x errors, 2% base, 2000 rps, 5% canary20 s (57%)300 s (100%)2 s (36.8%)
2x errors, 2% base, 2000 rps, 5% canary8 s (99.6%)300 s (100%)2 s (100%)
5x errors, 2% base, 2000 rps, 5% canary2 s (100%)300 s (100%)1 s (100%)
fails every request, 2% base, 2000 rps, 5% canary1 s (100%)300 s (100%)1 s (100%)
2x errors, 0.5% base, 2000 rps, 1% canary120 s (92.8%)300 s (98.2%)8 s (100%)
2x errors, 0.5% base, 2000 rps, 25% canary9 s (99.2%)300 s (100%)2 s (100%)
10% slow, 0.5% base, 200 rps, 5% canary30 s (99.4%)300 s (1%)27 s (76.6%)
99% slow, 0.5% base, 200 rps, 5% canary2 s (100%)300 s (0.6%)24 s (73.4%)
10% slow, 0.5% base, 2000 rps, 5% canary4 s (100%)300 s (1.2%)2 s (34%)
99% slow, 0.5% base, 2000 rps, 5% canary1 s (100%)300 s (0.6%)2 s (26.8%)

Dimensions that come and go

Dimensions register themselves with FiveFamilies, a registry on Raft, and the controller turns its list into a config version. This run has a stack of its own without fault injection, 500 rps across both gateways and two retries on the route: 0 of 443,047 requests were lost in 43 runs. Run 20261021-225536-laptop.

stepmedianfastestslowest
Registering22 ms15 ms46 ms
Registered to its first request15 ms8.8 ms30 ms
Registered to both gateways on the new config10 ms7.8 ms22 ms
Deregistering to both gateways without it15 ms11 ms19 ms
Registering while the leader is dead2106 ms652 ms8141 ms
Leader killed to the new dimension serving3875 ms1392 ms8904 ms
case, totals over its runsrunslostconfig versionsleases lost
A fifth dimension joins, then leaves200400
The registry's leader killed, back after 10 s10002
A dimension joins while the leader is dead100102
Every registry node down for 30 s30012

A lost lease costs nothing here: the dimension registers again with the same address and version, the list doesn't change and no config version is written.