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 1k | CPU/request at 4k | max rps | memory at max |
|---|---|---|---|---|---|
| Go httputil.ReverseProxy | +139 µs | +988 µs | 146 µs | 8k | 150 MB |
| PortalGun | +155 µs | +1.1 ms | 153 µs | 6k | 91 MB |
| nginx | +107 µs | +691 µs | 115 µs | 15k | 38 MB |
| HAProxy | +108 µs | +801 µs | 107 µs | 12.5k | 90 MB |
| Caddy | +201 µs | +1.7 ms | 171 µs | 4k | 103 MB |
| Envoy | +207 µs | +1.3 ms | 180 µs | 6k | 139 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.
| rate | proxy | p50 | p99 | CPU/request |
|---|---|---|---|---|
| 6k | Go httputil.ReverseProxy | 400 µs | 2.4 ms | 119 µs |
| 6k | PortalGun | 417 µs | 3.9 ms | 122 µs |
| 7k | Go httputil.ReverseProxy | 416 µs | 3.2 ms | 108 µs |
| 7k | PortalGun | 471 µs | 5.5 ms | 112 µs |
| 8k | Go httputil.ReverseProxy | 443 µs | 5.4 ms | 97 µs |
| 8k | PortalGun | 490 µs | 12 ms | 102 µ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.
| offered | 8k | 10k | 12k | 15k | 20k |
|---|---|---|---|---|---|
| Go httputil.ReverseProxy | 8k 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 cap | collapsed 256 MB + swap | collapsed 256 MB + swap | collapsed 256 MB + swap | collapsed 256 MB + swap | collapsed 256 MB + swap |
| PortalGun, cap 1024 | 8k 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 512 | 8k 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 256 | 8k 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 128 | 8k 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 | +p99 | Redis trips/request | admitted, even | admitted, shifting | admitted, burst | without Redis |
|---|---|---|---|---|---|---|---|
| Static split, no coordination | +10 µs | +199 µs | 0.00 | 0% | -34.4% | -27.5% | - |
| Redis on every request | +379 µs | +1.5 ms | 1.00 | +9.4% | -9% | -11.4% | 15,183 |
| Local bucket, sync every 10 ms | +11 µs | +3 µs | 0.25 | 0% | 0% | -0.1% | 0 |
| Local bucket, sync every 50 ms | +10 µs | +79 µs | 0.05 | 0% | 0% | -1% | 0 |
| Local bucket, sync every 100 ms | +1 µs | +46 µs | 0.03 | +0.1% | -0.2% | -1.3% | 0 |
| Local bucket, sync every 500 ms | +3 µs | +122 µs | 0.01 | -0.3% | -19.1% | -14.3% | 0 |
| Token lease, 5 ms | +154 µs | +597 µs | 0.43 | 0% | 0% | -0.1% | 1 |
| Token lease, 20 ms | +19 µs | +339 µs | 0.13 | 0% | 0% | -0.5% | 0 |
| Token lease, 100 ms | +8 µs | +210 µs | 0.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.
| design | admitted, even | admitted, shifting | admitted, 4 gateways, shifting | admitted, 10,000 keys | without 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 ms | 0% → 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.
| design | p50, Redis slow | admitted, Redis down | degraded, Redis down |
|---|---|---|---|
| Redis on every request | 351 µs | 0% | 52,419 |
| Local bucket, sync every 100 ms | 345 µs | 0% | 52,284 |
| Token lease, 20 ms | 353 µs | 0% | 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.
| fault | Probes | Passive | Both | Both + retries |
|---|---|---|---|---|
| Crash | 1,560 1500 ms | 10 0 ms | 6 0 ms | 0 0 ms |
| Connection reset | 1,226 2000 ms | 11 0 ms | 7 0 ms | 0 0 ms |
| Blackhole | 18 0 ms | 16 0 ms | 11 0 ms | 0 0 ms |
| 500 ms slower | 0 0 ms | 0 0 ms | 0 0 ms | 0 0 ms |
| 30% errors | 6,009 never | 185 1500 ms | 204 5900 ms | 0 500 ms |
| Flapping every 2 s | 3,961 never | 13 15900 ms | 11 15900 ms | 0 15900 ms |
One setting at a time
Probes, passive ejection and retries on, everything else at the default except the one thing named.
| case | lost | lost 10 s after | p99 during | moved |
|---|---|---|---|---|
| Blackhole, 100 ms per try | 0 | 0 | 5.9 ms | 0 ms |
| Blackhole, 250 ms per try | 0 | 0 | 5.8 ms | 0 ms |
| Blackhole, 1 s per try (default) | 0 | 0 | 5.8 ms | 0 ms |
| Crash, default | 0 | 0 | 5.9 ms | 0 ms |
| Crash, eject on the first refused connection | 0 | 0 | 5.8 ms | 0 ms |
| Crash, back at full weight without slow start | 0 | 0 | 5.9 ms | 0 ms |
| 500 ms slower, round robin | 0 | 0 | 554 ms | 1600 ms |
| 500 ms slower, least outstanding | 0 | 0 | 6.0 ms | 0 ms |
| 500 ms slower, P2C with peak EWMA (default) | 0 | 0 | 5.9 ms | 0 ms |
| 20% errors everywhere, no panic mode | 35,527 | 12,798 | 7.5 ms | never |
| 20% errors everywhere, panic below 50% healthy | 2,507 | 0 | 11 ms | never |
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 runs | 0.1% to 0.7% |
| Sequential test alone promotes undecided steps | 0.1% to 0.7% |
| Fixed horizon one exact test at the end of the step | 0.4% to 1.5% |
| z-test every second nominally 5% | 22.3% to 51.3% |
| Canary over 2x stable trailing 60 s | 1.6% to 90.6% |
| Canary over a fixed rate 2x the known normal rate | 0.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.
| canary | Sequential + terminal test | Fixed horizon | Canary over 2x stable |
|---|---|---|---|
| 1.2x errors, 0.1% base, 200 rps, 5% canary | 300 s (0.8%) | 300 s (1.4%) | 69 s (93%) |
| 1.5x errors, 0.1% base, 200 rps, 5% canary | 300 s (1.8%) | 300 s (6.8%) | 48 s (96.2%) |
| 2x errors, 0.1% base, 200 rps, 5% canary | 300 s (8%) | 300 s (17.2%) | 37 s (99.8%) |
| 5x errors, 0.1% base, 200 rps, 5% canary | 250 s (93%) | 300 s (95.8%) | 15 s (100%) |
| fails every request, 0.1% base, 200 rps, 5% canary | 1 s (100%) | 300 s (100%) | 10 s (100%) |
| 1.2x errors, 0.1% base, 2000 rps, 5% canary | 270 s (4.4%) | 300 s (11.2%) | 7 s (80.4%) |
| 1.5x errors, 0.1% base, 2000 rps, 5% canary | 254 s (34.8%) | 300 s (52.8%) | 5 s (95.8%) |
| 2x errors, 0.1% base, 2000 rps, 5% canary | 147 s (91.8%) | 300 s (97%) | 4 s (99.8%) |
| 5x errors, 0.1% base, 2000 rps, 5% canary | 27 s (100%) | 300 s (100%) | 2 s (100%) |
| fails every request, 0.1% base, 2000 rps, 5% canary | 1 s (100%) | 300 s (100%) | 1 s (100%) |
| 1.2x errors, 0.5% base, 200 rps, 5% canary | 300 s (2.2%) | 300 s (3.6%) | 22 s (88.2%) |
| 1.5x errors, 0.5% base, 200 rps, 5% canary | 260 s (14.6%) | 300 s (26.8%) | 15 s (96.4%) |
| 2x errors, 0.5% base, 200 rps, 5% canary | 220 s (61.2%) | 300 s (80%) | 14 s (99.8%) |
| 5x errors, 0.5% base, 200 rps, 5% canary | 53 s (100%) | 300 s (100%) | 11 s (100%) |
| fails every request, 0.5% base, 200 rps, 5% canary | 1 s (100%) | 300 s (100%) | 10 s (100%) |
| 1.2x errors, 0.5% base, 2000 rps, 5% canary | 54 s (4.4%) | 300 s (46.8%) | 2 s (39.2%) |
| 1.5x errors, 0.5% base, 2000 rps, 5% canary | 63 s (45%) | 300 s (100%) | 3 s (74.2%) |
| 2x errors, 0.5% base, 2000 rps, 5% canary | 30 s (95.6%) | 300 s (100%) | 3 s (100%) |
| 5x errors, 0.5% base, 2000 rps, 5% canary | 6 s (100%) | 300 s (100%) | 2 s (100%) |
| fails every request, 0.5% base, 2000 rps, 5% canary | 1 s (100%) | 300 s (100%) | 1 s (100%) |
| 1.2x errors, 2% base, 200 rps, 5% canary | 300 s (4.4%) | 300 s (15.6%) | 15 s (44.4%) |
| 1.5x errors, 2% base, 200 rps, 5% canary | 164 s (48.8%) | 300 s (84.6%) | 19 s (82%) |
| 2x errors, 2% base, 200 rps, 5% canary | 72 s (96.4%) | 300 s (99.8%) | 12 s (99.8%) |
| 5x errors, 2% base, 200 rps, 5% canary | 14 s (100%) | 300 s (100%) | 10 s (100%) |
| fails every request, 2% base, 200 rps, 5% canary | 1 s (100%) | 300 s (100%) | 10 s (100%) |
| 1.2x errors, 2% base, 2000 rps, 5% canary | 15 s (3.8%) | 300 s (97.8%) | 2 s (15.6%) |
| 1.5x errors, 2% base, 2000 rps, 5% canary | 20 s (57%) | 300 s (100%) | 2 s (36.8%) |
| 2x errors, 2% base, 2000 rps, 5% canary | 8 s (99.6%) | 300 s (100%) | 2 s (100%) |
| 5x errors, 2% base, 2000 rps, 5% canary | 2 s (100%) | 300 s (100%) | 1 s (100%) |
| fails every request, 2% base, 2000 rps, 5% canary | 1 s (100%) | 300 s (100%) | 1 s (100%) |
| 2x errors, 0.5% base, 2000 rps, 1% canary | 120 s (92.8%) | 300 s (98.2%) | 8 s (100%) |
| 2x errors, 0.5% base, 2000 rps, 25% canary | 9 s (99.2%) | 300 s (100%) | 2 s (100%) |
| 10% slow, 0.5% base, 200 rps, 5% canary | 30 s (99.4%) | 300 s (1%) | 27 s (76.6%) |
| 99% slow, 0.5% base, 200 rps, 5% canary | 2 s (100%) | 300 s (0.6%) | 24 s (73.4%) |
| 10% slow, 0.5% base, 2000 rps, 5% canary | 4 s (100%) | 300 s (1.2%) | 2 s (34%) |
| 99% slow, 0.5% base, 2000 rps, 5% canary | 1 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.
| step | median | fastest | slowest |
|---|---|---|---|
| Registering | 22 ms | 15 ms | 46 ms |
| Registered to its first request | 15 ms | 8.8 ms | 30 ms |
| Registered to both gateways on the new config | 10 ms | 7.8 ms | 22 ms |
| Deregistering to both gateways without it | 15 ms | 11 ms | 19 ms |
| Registering while the leader is dead | 2106 ms | 652 ms | 8141 ms |
| Leader killed to the new dimension serving | 3875 ms | 1392 ms | 8904 ms |
| case, totals over its runs | runs | lost | config versions | leases lost |
|---|---|---|---|---|
| A fifth dimension joins, then leaves | 20 | 0 | 40 | 0 |
| The registry's leader killed, back after 10 s | 10 | 0 | 0 | 2 |
| A dimension joins while the leader is dead | 10 | 0 | 10 | 2 |
| Every registry node down for 30 s | 3 | 0 | 0 | 12 |
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.