Module 8 / Rate limiting

Module 8 · lesson 4 of 7 · Read 9 min

Where to limit

Gateway, server or per feature, and what to send back.

You know how to count. Now three practical questions. Where in the request's path does the counting happen? What do you count by: IP address, user, phone number? And when a client goes over, what exactly do you send back?

Get these wrong and a perfectly good algorithm still fails. Count by IP in India and you'll block a whole college hostel because one student wrote a script. Reply with the wrong status code and Tiffin's own app will retry instantly and make things worse.

Layers, from the edge inward

A request to Tiffin passes through several places that could reject it. Each sees different information and costs a different amount to reach.

Phone
→
CDN or firewallknows only the IP
→
Load balancerIP, path, headers
→
App serverknows the user and the business
The further in, the more the layer knows, and the more it costs to get there.
Layer What it knows Good for Weakness
CDN or web firewall IP, country, request shape Floods, known bad IPs, crude bots Doesn't know who the user is
Load balancer or gateway (nginx) IP, path, headers, API key Per-IP and per-key limits, cheap and early Doesn't know business rules
App middleware Logged-in user, their plan Per-user API limits Request already used some app CPU
Inside a feature Everything: was the coupon wrong? which phone? Login, OTP, coupons, orders Most work already done

The rule of thumb: reject as early as you can, but only with information you trust at that layer. A flood of 50,000 requests a second from one IP should die at the edge before it costs you anything. "This user has tried 10 wrong coupons in an hour" can only be judged inside the coupon code, because only it knows the coupon was wrong.

Most real systems use several layers. Tiffin's managed load balancer from Module 4 can't count requests per IP, so an nginx reverse proxy in front of each app server holds a loose per-IP limit as a seatbelt against floods. (Behind the load balancer, nginx has to read the client's IP from X-Forwarded-For, trusting it only from the balancer, or it would count the balancer's address.) The app has per-user limits. A few sensitive features have their own strict limits. In Module 11 we'll give this a proper home in an API gateway, a single front door that handles limits, authentication and routing for many services.

Choosing the key

The key decides who shares a budget. Pick it wrong and either abusers slip through or innocent people get punished together.

IP address

The only key you have for requests that aren't logged in. Cheap and available everywhere. But an IP is not a person.

In India this is a big deal. Mobile carriers use carrier-grade NAT: they put many subscribers behind a single public IPv4 address because there aren't enough addresses to go round. Hundreds or even thousands of phones can share one IP at the same moment. Office networks, college hostels and some home broadband do the same on a smaller scale.

So a per-IP limit of 60 requests a minute could block a whole neighbourhood of Tiffin customers at 8pm because they share an IP with someone browsing fast. Per-IP limits must be generous, a seatbelt against floods, not a tool for fairness.

IPv6 has the opposite problem. A single phone can get a huge block of addresses and change them freely, so a script can rotate through millions of IPs. Limit IPv6 clients by their /64 prefix (the part of the address the network assigns), not the full address.

User ID

Once someone is logged in, this is the best key. One person, one budget, no matter which network they're on. An abuser can create more accounts, but that costs them a phone number and an OTP each time, which you also limit.

API key

For partners like a restaurant chain's point-of-sale system. Each partner gets their own key and a limit sized to their real volume. A big chain with 200 outlets gets more than a single café. This is also where tiers live: a partner on a higher plan gets a bigger bucket.

The thing being attacked

Some limits make sense only on the target. To stop OTP guessing, limit attempts per phone number, because the attacker can switch IPs and devices but not the number they're attacking. To stop SMS pumping, limit OTP sends per phone number and per IP and in total per hour, because the attacker uses thousands of numbers.

Endpoint Key Why
Anything, not logged in IP (generous) Nothing else is available
General API, logged in User ID Fair per person, unaffected by NAT
Partner API API key Per contract, per tier
Verify OTP Phone number Protects the target, whatever the attacker's IP
Send OTP Phone number + IP + global total Pumping uses many numbers
Apply coupon User ID, counting failures only Real users rarely mistype more than twice

Per-feature limits

A general limit of 300 requests a minute is fine for browsing. It's useless for login. At 300 a minute, an attacker could try 300 OTP codes a minute. Sensitive features need their own, much smaller limits, on top of the general one.

Tiffin's feature limits:

  • Send OTP: 3 per phone number per 10 minutes, 20 per IP per hour (generous, because many phones share one carrier IP), and an alarm if total sends go above 3x the normal rate.
  • Verify OTP: 5 wrong attempts per code. After that, the code is dead and a new one must be requested.
  • Apply coupon: 10 wrong codes per user per hour.
  • Place order: 10 per user per 10 minutes. Nobody orders dinner 11 times in 10 minutes.

Notice the coupon limit counts only failures. A customer applying the same valid code twice shouldn't use up their allowance. Only the coupon code knows whether the code was wrong, which is why this limit must live inside the feature.

Be careful with "lock the account after 5 wrong passwords". An attacker who knows your username can then lock you out on purpose, forever. Prefer slowing down (a growing delay, a CAPTCHA, a fresh OTP) to locking. The goal is to make guessing expensive, not to hand attackers a button that blocks real users.

What to send back

When a client is over the limit, reply with HTTP 429 Too Many Requests. Not 500 (that says your server broke), and not 503 (that says your server is overloaded for everyone). 429 tells the client: you specifically are asking too fast, and the server is fine. Clients and libraries react to those codes differently.

Tell the client when to come back, and how much budget it has:

HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
Content-Type: application/json

{
  "error": "rate_limited",
  "message": "You're going a little fast. Try again in a few seconds.",
  "retry_after_seconds": 12
}
  • Retry-After is a standard header, in seconds (or a date). Every client should honour it.
  • The X-RateLimit-* headers are a widely copied convention, not a standard, and they vary: GitHub's X-RateLimit-Reset is a Unix timestamp, while the example above sends seconds to wait. An IETF draft is standardising similar RateLimit headers, and the exact format is still settling. Pick one style, document it, and be consistent.
  • Sending the remaining budget on successful responses too lets well-behaved clients slow down before they hit the wall.

The body should be readable by humans. The Tiffin app shows that message instead of a generic "Something went wrong".

Other actions besides rejecting

Rejecting isn't the only option:

  • Delay. Queue the request briefly instead of refusing it, like nginx's burst without nodelay. Good for partner integrations that can wait.
  • Degrade. Serve a cached menu instead of a fresh one. The client still gets an answer and the database is spared.
  • Challenge. Ask for a CAPTCHA. Humans pass, most scripts don't.
  • Log only. Run a new limit in shadow mode first: count, record who would have been blocked, but let everything through. You'll see whether real customers would be hit before any of them are. Always do this for a new limit.

The client's side

Your own app must behave well when it gets a 429. If the Tiffin app retries immediately, a rate limit becomes a retry storm. The app should wait at least Retry-After, and spread retries out with backoff and a bit of randomness so thousands of phones don't retry at the same instant. Module 12 covers retries and backoff properly.

Fix the complaint

Tiffin's support team gets 40 complaints in one evening, all from customers on the same mobile network in one area of the city, saying "Too many requests" when they open the app. They aren't logged in yet. Today the only limit on logged-out requests is 100 per minute per IP. What's going on, and what would you change?

Show one answer

These customers are almost certainly behind the same carrier-grade NAT, sharing one public IP. Together they exceed 100 requests a minute, and everyone behind that IP gets blocked.

Changes:

  1. Raise the per-IP limit on logged-out requests a lot (say 2,000 a minute). It's a flood seatbelt, not a fairness tool.
  2. Give logged-out sessions their own key: an anonymous device token issued on first launch, with a per-device limit of a few hundred a minute. Scripts can fake tokens, so keep the IP seatbelt too.
  3. Move strict limits to keys that follow the person or the target: user ID once logged in, phone number for OTP.
  4. Next time, roll out the IP limit in shadow mode first, and you'd have seen these shared IPs in the logs before anyone was blocked.

What to remember

  1. Limit in layers: a loose per-IP seatbelt at the edge or load balancer, per-user limits in the app, strict limits inside sensitive features.
  2. Reject as early as possible, but only with information you trust at that layer. Business rules like "wrong coupon" can only be judged in the feature.
  3. An IP is not a person. Carrier-grade NAT puts thousands of Indian mobile users on one IP. Use user ID, API key or the attacked target (phone number) as the key wherever you can, and limit IPv6 by /64.
  4. Reply with 429, a Retry-After header and the remaining budget. 503 means something else.
  5. Try a new limit in shadow mode first, and make sure your own app backs off instead of retrying instantly.