Rate limiting & resilience ยท How it works
9 min readHow it works
Where limits live
- At the edge (API gateway, CDN, load balancer): cheapest place to reject abuse and protect everything behind it.
- Per service: protects a service from its own callers, including internal ones.
- Per dependency (client-side): a service limits how hard it calls a fragile downstream or a paid third-party API.
Limits are keyed by what you want to be fair between: API key, user, tenant, IP (weak โ NAT and proxies share IPs), or endpoint cost class.
A: Because IP addresses do not map to users. Many users share one address behind corporate NAT, carrier-grade NAT or a proxy, so a per-IP limit throttles innocent users together; meanwhile an attacker with a botnet or rotating cloud addresses gets a fresh limit per address. IP limits are useful as a coarse edge defence against floods from unauthenticated traffic, but fairness and abuse control should key on an authenticated identity โ API key, user or tenant.
The algorithms
| Algorithm | How it works | Strength | Weakness |
|---|---|---|---|
| Fixed window | Count requests per clock window (per minute) | Simplest, one counter | Burst of 2ร at the window boundary |
| Sliding window log | Store a timestamp per request, count those in the last window | Exact | Memory per request |
| Sliding window counter | Weighted mix of current and previous window counts | Smooth, two counters | Approximate |
| Token bucket | Bucket of capacity B refills at rate r; each request takes a token | Allows bursts up to B, average r | Two parameters to tune |
| Leaky bucket | Requests queue and drain at a fixed rate | Perfectly smooth output | Adds queueing delay, drops when full |
A: A client can send the full quota at the very end of one window and again at the very start of the next, so within a few seconds it sends twice the intended rate while never exceeding the per-window count. With a limit of 100 per minute, 100 requests at 12:00:59 and 100 at 12:01:00 are both allowed. Sliding windows fix it by counting over the last 60 seconds from now rather than per clock minute, and a token bucket fixes it by bounding bursts to the bucket size.
Token bucket
A token bucket stores two numbers per key: tokens and last refill time. On each request, add elapsed ร r tokens (capped at B), then take one if available. It permits short bursts up to B while holding the long-run rate to r, which matches how real clients behave. Weighted requests take more than one token (an export costs 10).
A: The bucket holds up to B tokens and refills at r tokens per second. A client that has been quiet accumulates tokens up to B and can spend them all at once โ a burst of B requests โ but after that it can only proceed at the refill rate r. Over any long interval it cannot exceed B plus r times the interval, so the average is bounded by r. Setting B controls how bursty clients may be, and r controls sustained throughput.
Distributed rate limiting
With many gateway instances, each must see the same counter.
- Central store: keep buckets in Redis and update them atomically with a Lua script or
INCRplus expiry. Accurate, adds a network hop per request, and Redis becomes a dependency (decide whether to fail open or closed when it is down). - Local limits with a share: each of N instances enforces limit/N locally. No hop, but uneven load balancing makes it inaccurate.
- Local plus periodic sync: instances count locally and reconcile with the central store every few hundred milliseconds. Near-accurate, cheap, slightly over-admits during bursts.
A: Store the per-key state centrally, usually in Redis, and make the check-and-update atomic โ a Lua script that refills a token bucket and takes a token in one round trip, or a sliding window counter with INCR and expiry โ so concurrent instances cannot both take the last token. Keep the store close to the gateways, and decide explicitly what happens if it is unreachable: fail open (allow traffic, protecting availability) for general APIs, fail closed for expensive or abuse-prone endpoints. If the extra hop is too costly, have each instance count locally and sync with the store every few hundred milliseconds, accepting a small overshoot.
Telling clients what happened
Reject with 429 Too Many Requests, a Retry-After header, and ideally RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers so well-behaved clients can slow down before they hit the wall. Use 503 with Retry-After when the service itself is overloaded rather than the client over its quota.
A: 429 means this client has exceeded its own allowance โ the problem is the caller's request rate, and other clients are unaffected. 503 means the service is temporarily unable to handle requests at all, regardless of who sent them, typically because it is overloaded or in maintenance. Both should carry Retry-After. The distinction matters for client behaviour and for monitoring: a spike of 429s is abuse or a misbehaving client, a spike of 503s is a capacity or health problem.
Cascading failures
A dependency slows down; callers hold threads and connections while they wait; their pools fill; they time out and retry, multiplying load on the struggling dependency; their own callers now see slowness and retry too. Every layer's retries multiply: three layers each retrying three times turns one failing request into 27 calls at the bottom.
A: They multiply. If each of three layers retries a failed call three times, one user request can produce 3 ร 3 ร 3 = 27 attempts against the bottom dependency โ exactly when it is already struggling. The extra load deepens the slowdown, which causes more timeouts and more retries. Fix it by retrying at one layer only (usually closest to the user or the failing call, not both), using retry budgets that cap retries to a percentage of normal traffic, backing off with jitter, and only retrying idempotent and transient failures.
Timeouts and retry budgets
- Every network call has a timeout, set from the dependency's p99 plus margin, and shorter than the caller's own deadline.
- Propagate deadlines: pass the remaining time budget downstream so work abandoned by the user is abandoned everywhere.
- Retry only transient, idempotent failures, with exponential backoff and jitter.
- Retry budget: allow retries to add at most, say, 10% to normal request volume; beyond that, fail fast.
A: Because the caller stops waiting at its own deadline, and any work still running below it after that is wasted. If the edge gives up after 2 seconds but a backend call has a 5-second timeout, the backend keeps a thread and a connection busy for 3 seconds serving nobody, reducing capacity exactly when the system is slow. Setting each hop's timeout inside the remaining budget โ ideally by propagating a deadline with the request โ frees resources as soon as the answer can no longer be used.
Circuit breakers
A circuit breaker wraps calls to a dependency. Closed: calls pass and failures are counted. Open: calls fail immediately (or use a fallback) without touching the dependency, giving it room to recover and freeing the caller's threads. Half-open: after a cool-down, a few trial calls go through; success closes the breaker, failure reopens it.
A: Timeouts bound how long each call waits, but a caller hitting a dead dependency still waits the full timeout on every request and keeps sending it load; retries make that worse. A circuit breaker notices the failure rate is high and stops calling altogether for a cool-down period, so the caller fails in microseconds instead of seconds (keeping its threads free and its own latency low), the dependency gets breathing room to recover, and a half-open probe detects recovery automatically.
Bulkheads
Named after ship compartments: partition resources so one failure floods only its compartment. Separate thread pools or connection pools per dependency, separate worker pools for heavy and light endpoints, separate deployments for critical and non-critical traffic, and per-tenant concurrency limits. If the recommendations service hangs, only the recommendations pool is exhausted; checkout still has its threads.
A: Isolating resources so that one failing part cannot consume the capacity others need. Example: a product page calls pricing, inventory and recommendations. With one shared pool of 200 HTTP connections, a hanging recommendations service can hold all 200 and take pricing down with it. With a separate pool per dependency โ say 100 for pricing, 60 for inventory, 40 for recommendations โ a recommendations hang exhausts only its 40, and the page renders without recommendations.
Backpressure, load shedding and graceful degradation
- Backpressure: a slow consumer signals producers to slow down โ bounded queues that block or reject when full, TCP flow control, reactive streams. An unbounded queue is a memory leak with a delay.
- Load shedding: when the server is at capacity, reject excess work early and cheaply (before parsing the body or calling dependencies), preferring to drop low-priority traffic first. Serving some users well beats serving everyone badly.
- Graceful degradation: decide in advance which features are optional โ recommendations, personalisation, live counters โ and turn them off or serve cached versions under stress so the core path survives. Feature flags and fallbacks make this a switch, not a deploy.
A: It hides overload until it is catastrophic. Work keeps being accepted, the queue grows, every item waits longer, and by the time items are processed the users who sent them have given up โ so the service spends its capacity on requests nobody is waiting for, while memory grows until the process dies. A bounded queue with rejection (backpressure or load shedding) keeps latency predictable for the requests it accepts and tells callers immediately to back off or try elsewhere.