Step 1 of 5 · Reading · ~4 min
Read
Distributed
Tiered Limits
A production API never runs one limiter. Each request passes through a stack, and your gateway will now check three tiers in order and tell the client which one said no. You will also reproduce, on the wire, the classic bug of stacked limiters: a client who is refused still spends someone else's quota.
A request is served only if every tier allows it
1. per-IP 5 per minute abuse guard, works before anyone logs in
2. per-user 3 per minute fairness guard
3. global 12 per minute capacity guard for the whole service
The tiers are ANDed. Each is the fixed-window counter you already built, keyed by tier, the identity that tier cares about, and the window. A request first meets the IP tier, then the user tier, then the global tier, and the first one that refuses ends the chain: the tiers after it are never consulted.
Where does the IP come from?
Behind a load balancer the TCP peer is the balancer, so the client's address arrives in the X-Forwarded-For header. Each proxy appends the address it saw to the end of the list:
X-Forwarded-For: 6.6.6.6, 203.0.113.7
Anyone can send their own X-Forwarded-For, so the left side is attacker-controlled; here 6.6.6.6 is a forged entry the client added. Only the entry your own proxy appended, the last one, is trustworthy when exactly one proxy sits in front of you. A client that rotates the forged part must not be able to dodge its per-IP limit. A header can also be split over several lines; HTTP says to join them with , in order (RFC 9110, section 5.3), which the starter already does. A request with no such header at all belongs to one shared unknown address.
The trap: early tiers pay for late rejections
A tier that allows a request has already counted it. If a later tier then refuses, the earlier tier's counter stays incremented. With per-IP budget 4 and per-user budget 2, and everyone behind one NAT address:
| request | IP counter | user counter | verdict |
|---|---|---|---|
| alice 1 | 1 | alice 1 | allow |
| alice 2 | 2 | alice 2 | allow |
| alice 3 | 3 | alice refuses | refused by user |
| alice 4 | 4 | alice refuses | refused by user |
| bob 1 | refuses (4 of 4) | not reached | refused by IP |
Bob has sent nothing before, yet he is refused, because alice's refused traffic spent the shared address's budget. Check the user tier first and alice's refusals never reach the IP counter, so bob is served.
Your options, in rising order of effort: order the strictest or most specific tier first; do a read-only check of every tier before committing any increment (two passes); or accept the skew. Many production systems accept it, because refunding counters atomically across tiers costs more than the unfairness it repairs. Whichever you pick, pick it on purpose. The other consideration pulls the opposite way: cheap in-process checks first, so a flood is refused before it ever costs a network call to a shared store.
Tell the client which wall it hit
A bare 429 from a three-tier stack is undebuggable: "slow down for a minute" and "you are done for the day" look the same. Name the tier that bound. GitHub does this with the x-ratelimit-resource header, naming the budget ("core", "search") that ran out. This gateway sends X-RateLimit-Resource: ip, user or global, along with Retry-After.
Common mistakes
- Trusting the left of
X-Forwarded-For, so a client rotates a forged first entry to get a fresh limit every request. - Stopping at the first tier you tried, or continuing past a refusal and charging tiers that should never have been touched.
- Reporting the wrong tier: when both IP and user would refuse, the answer is the first one in the chain.
- Sharing counters between tiers by forgetting the tier in the key.
Your exercise
Build the three-tier stack: IP, then user, then global, 60-second aligned windows, with the 429 naming the tier that refused.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…