limiter/ package (ex03 bucket + ex09 sharded keyed store + idle sweep, race-tested), server/ with /allow /stats /healthz and X-RateLimit headers, client/ demo replica with fail-open/closed and latency percentiles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
Experiment — a global rate limiter as a service
Exercise 03 built an in-process token bucket. That limits one process. When N replicas of a service must share one quota ("tenant-a gets 10 req/s, total"), the bucket has to live somewhere they can all reach. This is the smallest honest version of that: an HTTP service holding per-key buckets, and a demo client that plays one replica.
99-experiments/global-ratelimit/
limiter/ token bucket (ex03) + sharded keyed store (ex09) + idle eviction go test -race
server/ HTTP service: /allow, /stats, /healthz
client/ demo replica: asks /allow before each unit of work
Run it
# terminal 1 — the limiter
go run ./99-experiments/global-ratelimit/server -rate 10 -burst 10
# quick check
for i in $(seq 12); do curl -s -o /dev/null -w '%{http_code} ' 'localhost:8100/allow?key=t'; done
# 200 200 200 200 200 200 200 200 200 200 429 429
curl -si 'localhost:8100/allow?key=t' | grep -iE 'HTTP/|retry|ratelimit'
# terminals 2–4 — three replicas, each WANTS 20/s, all on key tenant-a
go run ./99-experiments/global-ratelimit/client -id inst-1 -rps 20
go run ./99-experiments/global-ratelimit/client -id inst-2 -rps 20
go run ./99-experiments/global-ratelimit/client -id inst-3 -rps 20
curl -s localhost:8100/stats
Observed (3 × 20 rps attempted, limit 10/s, 8 s):
inst-1 summary: attempts=159 allowed=31 denied=127 → work done=32
inst-2 summary: attempts=159 allowed=28 denied=130 → work done=29
inst-3 summary: attempts=160 allowed=29 denied=130 → work done=30
───
88 ≈ 10/s × 8 s + burst 10
inst-1 /allow latency: p50=0.6ms p99=11ms
The sum stays at the limit, not each instance — that's the whole point of centralising it. Each replica gets roughly a third, with no coordination between replicas and no per-replica configuration.
API
POST|GET /allow?key=K[&n=1]
200 {"allowed":true,"remaining":9,"limit":10,"rate":10}
429 {"allowed":false,"remaining":0.3,"limit":10,"rate":10,"retry_after_ms":70}
headers: X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After (429; whole seconds, rounded up)
400 missing key / bad n
GET /stats {"keys":2,"allowed_total":99,"denied_total":391}
GET /healthz ok
Server flags: -addr, -rate/-burst (defaults for unknown keys),
-limits "tenant-a=100:200,tenant-b=5:5" (per-key overrides), -sweep 1m.
Client flags: -id, -limiter, -key, -rps, -duration, -timeout 200ms,
-fail open|closed.
What to observe
The hop. Every decision is an HTTP round trip: p50 ≈ 0.6 ms on loopback, 1–2 ms across an AZ. The in-process limiter is ~100 ns. That's the price of "global", and why you only pay it at the edge (per-tenant quota), not in inner loops.
Fail-open vs fail-closed. Start a client with each mode, then Ctrl-C the server mid-run:
open allowed=0 denied=0 errors=10 → work done keeps going (49 of 59)
closed allowed=0 denied=0 errors=10 → work stops (18 of 59)
The limiter is now a dependency on the request path. Fail-open protects your
users and risks overloading what the limiter guards; fail-closed protects the
backend and turns a limiter outage into your outage. Pick deliberately, per
call site, and alert on the error rate either way. -timeout matters for the
same reason: a slow limiter must not make you slow.
Idle eviction. -sweep removes buckets untouched for that long. An idle
bucket is full anyway (once idle ≥ burst/rate), so recreating it later is
indistinguishable from keeping it — last doubles as the idle timestamp,
nothing extra to track. Without this, one bucket per API key ever seen lives
forever.
Burst semantics across replicas. The first second admits ~20 (burst 10 +
10/s). Replicas that start first eat the burst. If that's unfair for your use
case, lower burst — global buckets make the burst global too.
What this is not
- Not HA. One process, in memory. Restart = all buckets full again
(a free burst for everyone) and, for the duration, fail-open or fail-closed
as configured. Real deployments put the bucket in Redis and run the
refill+consume as one Lua script so it's atomic — same arithmetic as
limiter.Bucket.AllowN, different storage. - Not accurate under Redis Cluster / multiple limiter nodes unless keys are routed consistently to one node. Keys → node is what makes a "global" bucket global.
- No local tier. Production usually also keeps a coarse in-process limiter (ex03) as a pre-filter, so a flooding client is cut off before it costs a network hop per request, and caches "denied until T" locally.
References
- Stripe, "Scaling your API with rate limiters" — the token bucket + Redis pattern and why to shed load with it
- Envoy
ratelimitservice /local_ratelimitfilter — the two-tier design golang.org/x/time/rate— the in-process reference implementation