v5.0
live available
practices ranked 10 Sept 2026

Nine ways a 429 slips past your code, ranked by how long it hides

A local server that answers every request with 429, nine HTTP clients pointed at it, and the nine patterns that let a rate limit through without raising anything.

By Purvansh Parmar Checked 2026-09-10 Read 8 min Entries 9

A rate limit is not an outage. It is a well-formed HTTP response with a status code, a header or two, and usually a helpful JSON body, which is exactly why so much code walks straight past it and keeps going as though the call had worked. On Tuesday I spent an afternoon chasing a deploy that reported every file uploaded and then sat at state=uploading forever. The cause was a missing status check. So I built a server that returns nothing but 429s and pointed nine clients at it to see which ones would tell me.

How this was checkedToday I ran a local HTTP server that answers every request with 429, Retry-After: 7 and a JSON body, then pointed curl, wget, requests, httpx, urllib, urllib3's Retry, node fetch and node:http at it while counting requests server-side. curl -sS exits 0. Retry(total=3) spends 4 requests and 21.0 seconds on a GET and 1 request on a POST. Dropping the Retry-After header takes urllib3 from 3 requests down to no retries at all. The ranking itself comes from three bugs I actually hit: a deploy stranded at state=uploading because a 429 counted as a sent file, a demo-tier image search whose throttling got filed as no match, and an OTP limiter keyed on phone number alone across two tenants.

What I compared on

01 Exit zero on a 429

The request completed, so the client reports success. Nothing ever reads the status code.

Wins
Tolerable when something downstream reconciles the end state, such as a queue worker that compares against the server's own view before it acknowledges the job.
Loses
A deploy script that PUTs one file per API call counts every refused upload as sent. That is the bug I hit on Tuesday: the loop reported 86 of 86 uploaded, the deploy sat at state=uploading because the host was still waiting on bytes that never arrived, and the only symptom was a KeyError on the status poll. Reading %{http_code} fixed it, and the re-run needed exactly 2 files, which were the ones the 429s had dropped.
curl -sS: exit 01 request sent86 files, 86 API calls2 files silently lost

02 Throttle read as absence

A throttled search endpoint returns zero rows, and the caller writes down that nothing matched.

Wins
Acceptable on a read you are going to repeat anyway, where a later pass overwrites the empty answer with a real one before anybody depends on it.
Loses
It records wrong data and raises nothing at all. Tracing image licences against a demo key capped at 50 requests an hour, two items came back with zero candidates and were filed as no match. Remaining quota at that point was 7. The repair was a third verdict that cannot be read as absence, plus pacing raised from 50ms to 120ms between calls. The rule I wrote down afterwards was that a no-match is not evidence of absence.
50 req/hour on the demo tier1,000/hour once approved7 requests remainingpacing 50ms to 120ms

03 Error body parsed as data

The 429 arrives with well-formed JSON, and the parser is perfectly happy to hand it over.

Wins
Reading the body is the right instinct, becuase the body is where a vendor explains which limit you hit and how long it stays shut.
Loses
Node's fetch does not throw on a 429, and awaiting r.json() on one returns the error object as though it were your result. curl is worse: with `curl --retry 3` writing to a pipe it cannot truncate between attempts, so I measured four error bodies concatenated into one stream with exit code 0. Anything piping that into jq gets nonsense and a green exit. Send the same call to a file and you get one body, so the corruption depends on the redirection.
fetch: r.ok=false, no throw4 bodies concatenatedexit 0

04 Limiter keyed to the wrong identity

The bucket key looks unique and is not, so unrelated callers quietly share one quota.

Wins
Keying on the raw identifier is correct for a single-tenant service, where there is only one namespace available to collide in.
Loses
In a multi-tenant onboarding flow I found the OTP limiter keyed on phone number alone. Two tenants each had a guest using that number, so one tenant's attempts throttled the other's, and it surfaced as a 429 on a first-ever signup at the second tenant. The bucket lived in process memory, which made it cross-tenant shared state nobody had declared anywhere. The idempotency keys had the identical shape of bug, found in the same pass, and a suite of 100 assertions had missed both.
1 shared bucket2 tenants affected100 assertions missed it

05 Server-decided retries

Whether your client retries a 429 turns on an optional response header, not on the policy you set.

Wins
The default is defensible. With no server-supplied delay there is no safe interval to invent, so urllib3 declines to invent one rather than hammering a closed window.
Loses
urllib3 only treats a 429 as retryable when Retry-After is present, because 429 is not in the default status_forcelist and arrives instead through RETRY_AFTER_STATUS_CODES. Same code, same Retry(total=2): with the header I measured 3 requests over 14.0 seconds, and with the header absent, 1 request and no retry whatsoever. RFC 6585 section 4 makes that header a MAY, so a vendor trimming it disables your backoff without touching your code.
3 requests / 14.0s with the header1 request / 0.0s without itstatus_forcelist empty by default

06 Reads retry, writes do not

The default method allow-list covers GET and PUT but leaves POST out, so writes take the limit bare.

Wins
Refusing to replay a POST is the right default, since a retried non-idempotent write is how you double-charge a card or create the same record twice.
Loses
It stays invisible until a limit shows up, and then reads look healthy while writes fail. One session, one Retry(total=3): GET and PUT each made 4 requests over 21.0 seconds, POST made 1 and returned in 0.0 seconds. Nothing in the config says so. It comes from DEFAULT_ALLOWED_METHODS, which lists TRACE and OPTIONS but not POST, and it means your load test on read paths proves nothing about the write path.
GET: 4 requests / 21.0sPOST: 1 request / 0.0s6 methods allowed by default

07 Retries nobody configured

One logical call becomes four requests and twenty-one seconds, then hands you the 429 anyway.

Wins
Honouring the server's own delay is exactly right when you have the time budget for it, and it is why a single retry so often succeeds where an immediate one cannot.
Loses
Retry(total=3) against a server sending Retry-After: 7 spent 4 requests and 21.0 seconds of wall clock, then returned status 429 with no exception raised. Three of those four calls were pure quota burn inside a window that was shut. If your own timeout is 10 seconds, the library's backoff outlives your request and the incident you investigate is a timeout rather than a rate limit. Plain requests is the opposite trap: a default adapter is Retry(total=0), so it never retries at all.
4 requests21.0s wall clock3 wasted callsbackoff_max 120s

08 A quota counted in requests

You think in deploys, or pages, or jobs. The limit counts HTTP calls and the ratio is not one.

Wins
Per-request accounting is the honest unit for a provider to publish, because a request is what actually costs them something.
Loses
Netlify allows 500 requests a minute, and separately 3 deploys a minute and 100 a day. A deploy is one API call per changed file, so a single 86-file publish is 86 requests against the first limit while barely registering against the second. Eight deploys across a working session look like nothing on a dashboard built around deploy counts, and they were enough to start refusing my PUTs.
500 req/min3 deploys/min100 deploys/day86 files = 86 calls

09 A 429 from the edge

The refusal came from your own CDN rule, not from the API named in the stack trace.

Wins
An edge rule is the right place to put this. It survives every redeploy because it lives on the zone rather than in the application, and it costs the origin nothing at all.
Loses
The response carries none of the X-RateLimit headers your client inspects, because the upstream API never saw the request. On Cloudflare's free plan I set the single rule it allows, 5 requests per 10 seconds per IP on a write path, then watched requests 1 to 5 return the ordinary 400 validation error while 6, 7 and 8 came back 429. The payment provider behind it sits at a Tier 0 ceiling of 40 requests a second and 240 a minute, and was never remotely close.
1 rule on the free plan10s period, the only one offered5 req/10s per IPorigin ceiling 40/s and 240/min

Side by side

Stays hidden untilMeasured
Exit zero on a 429a third party tells you the state is wrongcurl -sS exits 0; 2 files lost
Throttle read as absencenever, the wrong answer is already written0 candidates at 7 of 50 left
Error body parsed as datathe garbage reaches something strict4 bodies concatenated, exit 0
Limiter keyed to the wrong identityanother tenant files the bug1 bucket, 2 tenants
Server-decided retriesthe vendor changes a header3 reqs with it, 1 without
Reads retry, writes do notthe write path meets a limitGET 4 reqs, POST 1
Retries nobody configureda latency budget blows4 reqs, 21.0s
A quota counted in requestsa 429 you cannot account for86 files, 86 calls
A 429 from the edgeimmediately, on the wrong service5 req/10s, blocked at 6

Verdict

Exit zero on a 429 is the one to fix this week. It is the only pattern here that has already cost me a broken deploy, and the repair is smaller than the bug: read the status code, treat 408, 429 and 5xx as retryable, honour Retry-After when it is present. Then go and find out what your library is doing behind your back. A default requests adapter is Retry(total=0) and never retries, while Retry(total=3) buys you four requests and twenty-one seconds on a GET and a single bare attempt on a POST. Choose one of those deliberately. The default was chosen by somebody solving a different problem from yours.

Sources