ALL POSTS
rate limitingapi designhttp

Your 429 Too Many Requests response is probably wrong

Most 429 responses ship a bare status code and a guessed Retry-After. Here is what the header should actually say, the RateLimit fields that replaced the old three-header pattern, and when a 402 is the more honest answer.

Sorower

Sorower

Co-founder

Jul 24, 202612 min read
In this article

A bare 429 Too Many Requests is the digital version of a locked door with no hours posted. The client knows it can't come in right now. It has no idea when to knock again, so it does the worst possible thing: it knocks immediately, and then a hundred of its retries knock too. That is how a single rate limit turns into a self-inflicted outage. Returning the status code is the easy 10% of a 429. The other 90% is the two or three headers that tell a well-behaved client exactly how to back off, and most APIs either skip them or copy a header format the standards body has already moved past.

This one is about getting the response right from the server's side, because almost every retry storm you have ever debugged started with a server that under-communicated. If you only fix one thing after reading this, make it the header in the next section.

Illustrated cover: a turnstile with a 429 sign pacing requests through, flanked by a Retry-After clock and a rate-limit gauge

What a 429 Too Many Requests actually promises

HTTP 429 was defined in RFC 6585, and the spec is refreshingly blunt about what it means: the user has sent too many requests in a given amount of time. That is it. It is not "your request was malformed" (that's 400) and not "you're not allowed" (that's 403). A 429 says the request was fine, you just have to slow down and try it again later.

The word doing all the work is later. RFC 6585 notes that the response "MAY include a Retry-After header indicating how long to wait before making a new request." MAY. That single optional word is why the internet is full of clients hammering servers a millisecond after getting told to stop. The status code is a notification; the headers are the contract. Skip the contract and the client has to guess, and a guessing client under load always guesses the same wrong thing at the same wrong time as every other client.

What happens to a client that gets nothing but the code?

It retries on its own schedule, which is usually "right now, then right now again." If a thousand clients hit your limit in the same second and none of them got a wait hint, they will all retry together, get 429'd together, and retry together again. That synchronized wave is the thundering herd, and it is entirely preventable from the server side. You already computed when the window resets in order to return the 429. The only remaining job is to tell the caller.

Retry-After: the one header most 429s forget

Retry-After is defined in RFC 9110, and it accepts two very different shapes. This is the part that trips up both servers and clients.

Retry-After: 120
Retry-After: Wed, 21 Oct 2026 07:28:00 GMT

The first is delay-seconds: a plain non-negative integer number of seconds. The second is an HTTP-date: an absolute timestamp in IMF-fixdate format. Both are legal. Both mean "don't come back before this." And the single most common client bug in the wild is assuming Retry-After is always the first form, calling parseInt on it, getting NaN for the date form, and retrying instantly. If you emit the date form, a meaningful slice of your callers will treat it as no header at all.

Pick one form and be consistent. For rate limiting, delay-seconds is almost always the better choice: it needs no clock-skew handling on the client, and skew across machines is real. Reserve the HTTP-date form for genuinely scheduled events like a maintenance window, where an absolute time is what you actually mean.

What if I genuinely don't know how long the client should wait?

You know more than you think. You are enforcing a window, so you know when it rolls over. Send the seconds until then. If your limiter is a token bucket, send the seconds until the next token is available. The only time you're truly blind is behind a limiter you don't control, and in that case a conservative fixed value (say, a few seconds) beats an omitted header every time. An honest overestimate costs a little latency. A missing header costs you a retry storm.

The RateLimit headers you're probably copying from 2015

Retry-After tells a client when it may retry the request it just made. It says nothing about how much budget the client has left before the next 429. That is what the RateLimit family is for, and it is where most tutorials are quietly out of date.

Infographic: three eras of rate-limit headers, from ad-hoc X-RateLimit-* to the three-field IETF draft to the modern structured RateLimit plus RateLimit-Policy

There have been three eras of rate-limit headers, and you can date any blog post by which one it teaches:

  • The ad-hoc era: X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset. Popularized by GitHub, Twitter, and a dozen others, never standardized, and inconsistent in the one field that matters. Some servers make Reset a Unix epoch timestamp; others make it seconds-from-now. A client can't tell which without reading your docs, which defeats the point of a machine-readable header.

  • The early-standard era: RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset. The IETF's first cut dropped the X- prefix and gave the three fields a single agreed meaning instead of a per-vendor guess. Better, but still three loosely related headers a client had to correlate by hand.

  • The structured era: RateLimit and RateLimit-Policy. The current draft folds everything into two IETF structured fields. One advertises the policy; the other reports where you stand against it.

Here is the shape the current draft defines, straight from its own example, on a normal successful response:

HTTP/1.1 200 OK
RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400
RateLimit: "burst";r=50;t=30
Content-Type: application/json

{"data": "..."}

Read it like this. RateLimit-Policy declares two named quota policies: a burst policy of q=100 requests per w=60 second window, and a daily policy of q=1000 per w=86400 seconds. RateLimit then reports the live state for the policy that's biting first: under burst, r=50 requests remain, and t=30 is the effective window in seconds, the span in which the client may use no more than that remaining quota. Two optional parameters round it out: qu names the quota unit (default is requests, so you could meter by, say, "reads") and pk carries a partition key when the limit is scoped to something like an API key or tenant.

Most "rate limit headers" snippets on the web are still teaching the ad-hoc era. That's not a scandal, the structured fields are still an Internet-Draft rather than a ratified RFC, but the draft is on the Standards Track and it's the direction gateways are heading. Emitting the modern RateLimit field costs you nothing and future-proofs your clients. If you want to keep the old X-RateLimit-* headers around for existing consumers, send both; they don't conflict.

What if a response carries both Retry-After and RateLimit?

Then keep them consistent, and the draft is explicit about the one rule that matters: Retry-After should not point to a moment earlier than the end of the effective window. Telling a client "retry in 5 seconds" while RateLimit says the window resets in 42 just invites it back into a wall. Make Retry-After equal to (or later than) t.

429 vs 402 vs 503: pick the code that tells the truth

"Too many requests" is not the only way a caller gets throttled, and reaching for 429 every time flattens three genuinely different situations into one. Clients build their retry logic around the code you return, so the code is a promise about whether retrying will even help.

Decision infographic: which throttle status code to return, comparing 429 Too Many Requests, 503 Service Unavailable, and 402 Payment Required

  • 429 Too Many Requests means the caller is going too fast right now. Retrying after a short wait will succeed. This is the rate-limit code.

  • 503 Service Unavailable means the server is overloaded or down, not the client's fault at all. It also carries Retry-After, and it says "it's us, come back shortly."

  • 402 Payment Required means the caller is out of allowance for the period. Retrying in a second changes nothing; they need a refill, a plan bump, or the next billing cycle.

That last distinction is the one nobody writes about, and it matters more as APIs move from throughput limits to usage-based quotas and credits. If a customer has burned their monthly allotment, a 429 with Retry-After: 1 is a lie. They can wait a second, a minute, an hour, and it still won't work. The honest answer is closer to "you're out of quota," which is why some usage-metered platforms answer with 402 instead of 429 for that case.

ReqKey is one of them. It meters usage as credits at the consumer level rather than capping requests per second, so when a consumer's balance hits zero, the validation endpoint doesn't say "slow down," it says "you're out." Here is exactly what that looks like:

curl -X POST "https://api.reqkey.com/key/validate" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{"key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2", "credits": 1}'

When the pool is empty, that returns a 402 Payment Required, not a 429:

{
  "valid": false,
  "message": "Credit limit exceeded",
  "creditsRemaining": 0,
  "creditsLimit": 10000
}

Here's the honest tradeoff, because 402 is not free of downsides. Plenty of HTTP client libraries auto-retry 429 and 503 out of the box but treat 402 as a hard error they won't retry. For quota exhaustion that's actually the behavior you want, retrying is pointless, but it means if you adopt 402 you have to document it, since some tooling surfaces 402 as a billing problem and confuses a support engineer at 2 AM. 429 remains the safe, universally understood default; 402 is the more precise answer if you're willing to explain it. Pick deliberately, and whichever you choose, put creditsRemaining (or your equivalent) in the body so the client knows why.

The client you're implicitly designing

Every header decision above is really a decision about the client's behavior. Set the contract well and a correct client falls out of it almost for free. Here is the retry loop your 429 is asking every caller to write, handling both Retry-After forms and falling back to backoff when the header is absent.

// Retry a fetch on 429/503, honoring Retry-After when the server sends it.
async function fetchWithRetry(url, options = {}, maxRetries = 5) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(url, options);
    if (res.status !== 429 && res.status !== 503) return res; // done, success or a real error
    if (attempt === maxRetries) return res;                    // give up; let the caller handle it

    const headerWait = retryAfterMs(res.headers.get("Retry-After"));
    const waitMs = headerWait ?? backoffWithJitter(attempt);    // no header: back off ourselves
    await sleep(waitMs);
  }
}

// Retry-After is EITHER an integer of seconds OR an HTTP-date. Handle both.
function retryAfterMs(value) {
  if (!value) return null;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return seconds * 1000;         // "120"
  const when = Date.parse(value);                              // "Wed, 21 Oct 2026 07:28:00 GMT"
  if (!Number.isNaN(when)) return Math.max(0, when - Date.now());
  return null;                                                 // unparseable: fall back to backoff
}

// Full jitter: a random wait in [0, base * 2^attempt], capped.
function backoffWithJitter(attempt, base = 500, cap = 30000) {
  const ceiling = Math.min(cap, base * 2 ** attempt);
  return Math.random() * ceiling;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

The jitter is not decoration. Plain exponential backoff still synchronizes every client onto the same 1s, 2s, 4s ticks, so the herd just stampedes on a delay. Randomizing the wait spreads the retries across the window and is the difference between a limiter that sheds load and one that amplifies it. If you want the server-side companion to this, the algorithm that decides when to return the 429 in the first place, we walked through an atomic sliding-window limiter in Redis and Lua here.

Four-step diagram of what a client should do with a 429: receive it, read Retry-After, wait or back off with jitter, then watch RateLimit

For a short, practical walkthrough of the client half (backoff, jitter, and retry logic against 429 and 5xx), this explainer is a good watch:

Video thumbnail: How to Implement Exponential Backoff and Retry Logic for 429 and 5xx responses

Should a 429 ever go out with no Retry-After at all?

Rarely, and only on purpose. If you're actively defending against an abusive client you may not want to hand it a precise "come back at exactly this millisecond" schedule to optimize against. For that narrow case, omitting Retry-After (and leaning on a firewall) can be defensible. For every normal, well-meaning caller, the omission is just cruelty dressed up as security. Default to sending it.

Key takeaways

  • Always send Retry-After on a 429. The status code tells a client it failed; the header tells it what to do next. Without it, correct clients are forced to guess, and under load they all guess wrong together.

  • Commit to one Retry-After format, and make it delay-seconds. Callers routinely mis-parse the HTTP-date form and retry instantly. Seconds also sidesteps clock skew between machines.

  • Emit the structured RateLimit field, not just X-RateLimit-*. The old three-header pattern is inconsistent about reset semantics; the modern field is self-describing and Standards-Track. Sending both keeps old clients working.

  • Match the code to the situation: 429 for too-fast, 503 for server trouble, 402 for out-of-quota. Retrying only helps for the first two, so returning the right one saves your callers a pointless retry loop.

  • Put the numbers in the body. A machine-readable reason (remaining, limit, reset) turns a dead-end error into something a client can act on without paging a human.

None of this requires a new dependency. It's a header and a status code you were already returning, said clearly instead of half-said. If you'd rather not hand-roll the limiter, the header logic, and the usage accounting behind all of it, that's precisely the layer ReqKey handles at the edge, with credit-based quotas and clean validation responses out of the box. The free tier is enough to wire it into a real service and watch the responses for yourself, and the docs have the exact request and response shapes. However you build it, decide what your 429 says before your traffic decides for you. Related reading: rotating credentials without a hard cutover in API key rotation without downtime.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.