harness-kit wiki

Rate Limiter

System Design series #2. Topic skill: skills/rate-limiter/. Design distributed rate limiting at scale, from algorithm to operation.

1. The problem and why it is deceptive

From far away, rate limiting looks like "a counter with TTL in Redis". That solves part of it. It does not solve the whole problem. When the system grows, the questions that matter are bigger than the algorithm:

The organizing question, asked first:

What exactly am I protecting, and how much imprecision do I accept to protect it without destroying latency and simplicity?

That one question drives almost every decision here.

2. Why rate limiting exists (five goals at once)

  1. Protect finite capacity of a system.
  2. Fairness between clients, tenants, users.
  3. Reduce blast radius: a client in a loop, a deploy generating anomalous traffic.
  4. Sustain commercial models: free / pro / enterprise plans.
  5. Control cost: services that call expensive dependencies (LLM, search, third parties).

A good design is a set of overlapping limits (global, tenant, user, endpoint, IP security), not a single counter. Policy first, Redis second.

3. Where to enforce (defense in depth)

Layer Good for Limit type Caveat
Edge / CDN absorb volumetric abuse, L7 DDoS, block early coarse lacks rich business context
API Gateway most common place; per token/user/tenant/endpoint generic API if it bottlenecks, the whole platform feels it
Inside the service limit depends on domain context (expensive report, LLM inference) semantic / cost closest to the truth, furthest from the edge

The mature architecture is layered: edge for coarse protection, gateway for generic API limits, service for semantic/cost limits. Each layer catches what the layer above cannot see. This is the same "defense in depth" idea as security layering.

4. The limit key

Choose the dimension(s) consciously: user, token, IP, tenant, endpoint, method, region, often a composite. The key choice sets:

Composite keys (tenant + endpoint) localize limits precisely but multiply the counter count.

5. Theory, the algorithms

This is the part everyone names and few explain. Each row is a different answer to "how do we count".

Fixed window counter

Count requests in a fixed clock bucket (e.g. per minute), reset at the boundary. Cheap, one counter per key. Flaw: boundary burst. A client can send a full window at 11:59:59 and another full window at 12:00:00, ~2x the intended rate across the boundary.

Sliding window log

Store a timestamp per request, count those within the trailing window. Exact and fair. Cost: memory and CPU grow with traffic, since you keep every timestamp in the window. Fine at low volume, expensive at scale.

Sliding window counter

Approximate the sliding window with two fixed buckets (current + previous) weighted by the elapsed fraction of the current window:

estimate = current_count + previous_count * (1 - elapsed_fraction)

Smooths the boundary burst, one or two counters per key, a single atomic operation. The common production compromise between fixed-window cheapness and sliding-log accuracy.

Leaky bucket

Requests enter a queue that drains at a constant rate; overflow is rejected. Produces a smooth, constant output rate, good for shaping traffic into a downstream that wants steady flow.

Token bucket ← industry default

A bucket holds up to capacity tokens. Tokens refill at a constant rate. Each request consumes one token; if the bucket is empty, reject (or queue). The key property: it separates the allowed burst (capacity) from the sustained rate (refill). A client can burst up to capacity instantly, then is held to rate thereafter. This matches how platforms want to behave: "you may spike a little, but your steady-state is capped."

on request:
  now = clock()
  tokens = min(capacity, tokens + (now - last_refill) * rate)
  last_refill = now
  if tokens >= 1: tokens -= 1; allow
  else: reject (429, Retry-After)

Token bucket is the classic traffic-shaping algorithm from networking (see Tanenbaum, Computer Networks); it is what most API platforms and cloud providers expose.

Choosing

Default to token bucket when you want sustained rate + controlled burst; sliding window counter when you want a simple, near-exact rolling limit with a sub-ms check. Make the check atomic: a Lua script in Redis runs read-decide-write in one server-side operation, so concurrent replicas cannot race.

6. State and storage

7. Theory, hot keys

One tenant with disproportionate traffic concentrates load on one store shard (a hot key). Mitigations:

8. Fail-open vs fail-closed

When the rate-limit store fails, decide deliberately.

Most platforms fail open on the generic path and fail closed only where the limit guards a hard capacity or cost ceiling. State which posture each layer takes, and why. Pair it with a circuit breaker (Nygard) so a slow store does not add latency to every request, once the store is detected unhealthy, the limiter stops calling it and applies the fallback posture immediately.

9. Hard vs soft limits

A hard limit rejects (429 Too Many Requests + Retry-After). A soft limit warns, degrades, or queues but still serves. Cost and fairness limits are often soft with escalating friction; security and capacity limits are hard.

10. Multi-region

Strict global accuracy across regions would require a synchronous cross-region hop on every request, which destroys latency. The realistic compromise is regional budgets with reconciliation: each region enforces a local share of the global limit and reconciles asynchronously. You accept bounded over-admit (a global limit of N might briefly admit a bit more than N) in exchange for low latency. Reserve strict global counting for the few limits that genuinely need it. This is the CAP/PACELC trade-off made concrete: under normal operation you trade consistency for latency.

11. Shadow mode (the highest-leverage practice)

Before enforcing a hard limit, run it in observation mode: the system computes the block decision but does not apply it, and emits what it would have blocked. This catches misconfigured policies before they cause an incident, you see "this new limit would have 429'd 40% of tenant X's legitimate traffic" in a dashboard instead of in a 3am page. Promote a limit from shadow to enforce only after the shadow metrics look right.

12. Observability

Essential metrics:

At 3am someone must be able to answer: which limit fired, in which dimension, was the policy wrong, was there a traffic regression? If the design cannot answer that, it is not operationally governable , and "operationally governable" is the real bar, not a pretty algorithm.

13. Failure modes

Failure Impact Mitigation
Store down no central counting chosen posture (fail-open default) + local fallback budget + circuit breaker + alert
Hot key saturates a shard shard CPU spike, latency local pre-filter + composite keys + local budgets
Bad policy pushed mass false 429s shadow mode first; fast config rollback; per-policy block-rate alert
Gateway limiter becomes the bottleneck platform-wide latency push coarse limits to edge, keep the gateway check O(1)

14. Incremental plan

  1. Vertical slice: one enforcement layer (gateway), fixed or sliding window, single Redis, one limit key, 429 + Retry-After, allow/deny metrics.
  2. Correctness / ops: token bucket via atomic Lua, fail-open + circuit breaker, shadow mode, hot-reload config, per-policy metrics.
  3. Scale: sharded store, hot-key local budgets + pre-filter, layered enforcement (edge/gateway/service).
  4. Global: regional budgets with reconciliation, composite policies, cost/LLM semantic limits in the service.

15. Trade-offs to state explicitly

Accuracy vs latency (exact global count vs local budget); fail-open vs fail-closed; central store vs local budgets; one limit key vs composite; enforce now vs shadow first; per-layer placement (catch early at edge vs rich context in service).

16. A worked, filled example

The repo ships a complete filled SDD for a distributed rate limiter (sliding-window-counter variant, 200k QPS, sub-ms budget, fail-open) as the agent's good example: good-system-design-example.md.

References

This page mirrors Rate Limiter in the wiki. Edit it there.