Rate limiting — how to test the limits everyone remembers only after an incident
Rate limiting is like a seatbelt: until the crash happens, its absence is imperceptible. A product can live for years without limits — and then one enthusiast with a script enumerates your promo codes, the search autocomplete hammers a heavy endpoint on every keystroke, or client retries after a brief outage finish the server off for good. That’s when limits appear — overnight, on adrenaline, untested.
I prefer the other order: limits are a feature, they have requirements, and they can be tested in advance. And you need to test both sides of the contract: that the server restricts — and that the client survives the restriction.
Why limits exist (and where they’re mandatory)
A limit isn’t one protection but four different ones, each with its own requirements. Enumeration: login, OTP codes (a six-digit code without a limit brute-forces in reasonable time — that’s 10⁶ options), promo codes, “is this email taken” checks. Money: endpoints that spawn SMS/emails/paid calls — the cost amplification I covered in the OWASP API Top 10 breakdown. Stability: protection from retry storms and one heavy client — that was the idempotency and retries post. Fairness: API quotas by pricing tier, where the limit is literally what customers pay for.
The tester’s first question isn’t “does the limit work” but “does it exist on all the critical zones at all.” Login, password reset, OTP, promo codes, message sending, heavy search, exports — for each: what limit, in what scope? “It’s not written down anywhere” is already a finding.
The boundary and an honest 429
The basic test is boring and mandatory: N requests pass, N+1 hits the wall. Boundary ±1, as in any validation checklist. Then — the quality of the response: it should be a 429 Too Many Requests (introduced by RFC 6585), with a Retry-After header and a machine-readable body — not an nginx HTML page and not a mysterious 500. The client needs to know two things: “this is a limit, not an outage” and “when to try again.” If the response doesn’t communicate that, clients will retry blindly — meaning the limit itself provokes the storm it was supposed to prevent.
And the recovery check: the window passed — the limit released. Sounds obvious, but a “stuck” limit (the reset doesn’t work, the user is blocked forever until manual intervention) is a real bug class that from the outside looks like “random users are completely broken.”
Key scope: the most interesting part
What key is the limit counted by — user, IP, token, device? This is where the most expensive mistakes live, and each option has its own set:
- An IP-based limit punishes the innocent: an office, a university, a mobile carrier behind NAT — hundreds of people on one address. Test: hit the limit — and check that you are locked out, not “everyone from this network.”
- An account-based limit on login is a ready-made DoS of someone else’s account: the attacker enters the victim’s wrong password N times — and the victim is locked out. Sound schemes combine signals (IP + account, progressive delays, captcha) — but which scheme you have usually only the code’s author knows. Ask.
- Trusting X-Forwarded-For: if the limit is per-IP and the IP comes from a header the client can set — the limit is bypassed with one curl flag. A classic, checked in a minute.
Separately — consistency across paths: there’s a limit on /api/v1/login, but on /api/v2/login or the mobile app’s endpoint with the same function — forgotten. GraphQL is a special case: one HTTP request can carry a hundred operations, and a “per-request” limit doesn’t see it (you need depth/complexity limits — also from the OWASP breakdown).
Windows: the burst at the boundary
If the limit is a fixed window (“100 requests per minute, the counter resets at :00”), a double burst passes at the boundary: 100 requests in second 59 and another 100 in second 01 — 200 requests in two seconds under a “100 per minute limit.” For a stability-protecting limit that can be critical. The test is simple: hit the limit at the end of a window and repeat right after the reset — what actual burst got through? Sliding window and token bucket don’t suffer from this, but what’s implemented in your system is again a question worth answering before the incident.
The client side: 429 is part of the UX
The second half of the contract, tested even more rarely: what does your own client do upon receiving a 429? Bad answers: a blank screen, “unknown error,” an instant automatic retry (which guarantees the user never escapes the limit). Good ones: a clear message (“too many requests, try again in a minute”), a temporarily disabled button, auto-retry — only with backoff and respect for Retry-After.
Testing this against the real limit is awkward, and unnecessary: a 429 mocks easily — page.route() in Playwright (shown in the auth post), Map Local in Proxyman/Charles for the mobile client. A special note on mobile: the offline action queue must not fire the whole batch at once upon reconnecting — that’s a self-made burst that bans itself.
Staging without limits = untested production
The most frequent finding: limits are disabled on the test environment — “so they don’t interfere with autotests.” The origin is understandable: real limits in CI mean flakiness, parallel workers ban each other (covered in the tests-in-CI post). But the result is that the configuration actually living in production is never tested at all — the same sin as a stage without caches while production runs with them.
The working compromise: in everyday CI the limits are relaxed or the test runners are whitelisted — deliberately and explicitly; but there exists a run (pre-release, on a prod-like environment) where limits are on in combat configuration and the boundary, response, and recovery tests run explicitly. For reproducible hammering, k6, hey, or curl in a loop suffice — the point is that the run is a script, not “I clicked around real quick.”
The rate limiting testing checklist
- Inventory: every critical zone (login, OTP, reset, promo codes, sending, search, export) has a known limit
- Boundary: N passes, N+1 → 429; recovery after the window works, nothing gets stuck
- Response: 429 + Retry-After + machine-readable body, not HTML and not a 500
- Scope: which key; NAT doesn’t ban the innocent; the login limit can’t DoS someone else’s account
- Client-supplied X-Forwarded-For doesn’t bypass the limit
- Sibling paths: v1/v2, mobile API, GraphQL (depth/complexity) — the limit is consistent
- Fixed window: the actual burst at the window boundary is measured and acceptable
- Client on 429: message, pause, backoff + Retry-After; no instant auto-retry
- The mobile client’s offline queue doesn’t fire as one batch
- Staging: limit relaxation is deliberate; a run with combat configuration exists
Limits are the rare case of “the feature is invisible while it works.” That’s exactly why they go untested: no visible behavior — no test cases. Until one user with a script shows up — and it turns out the test cases were needed last quarter.