Skip to content
Control Plane Labs

429 Too Many Requests

Client error response defined by RFC 6585 §4.

Written and maintained by Ben Ennis

Last reviewed July 27, 2026 · How we verify this

Common causes at a glance

  • A client exceeding a documented per-token or per-endpoint quota
  • A per-IP edge limit tripped by many users behind one NAT or proxy
  • A retry storm where failures are retried faster than the limit allows
  • A burst or concurrency limit fired even though the average rate is fine

Reproduce it

curl -sS -o /dev/null -D - https://api.example.com/v1/search | grep -i -E 'HTTP/|retry-after|ratelimit'

What 429 Too Many Requests means

429 says nothing about what you asked for and everything about how often you asked. The request was well-formed and probably authorised; a counter somewhere hit its ceiling. The critical detail is which counter.

Edge limits are per-IP and enforced by a CDN or WAF before the application sees anything. Application limits are per-token or per-endpoint and enforced by your own code. An office behind one NAT address can trip an edge limit while every user sits under their per-token quota, and one greedy service account can exhaust a per-token quota from a dozen IPs. The response headers usually reveal which fired, if anyone set them.

What the spec says

RFC 6585 §4 defines 429 and says the representation should explain the condition and may include a Retry-After header, defined in RFC 9110 §10.2.3 as either delta-seconds or an HTTP-date. Two omissions matter: the spec does not define how limits are counted, and it standardises no header for remaining quota. The ubiquitous X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset trio is convention, not specification, and vendors differ on whether Reset is a Unix timestamp or a count of seconds. RFC 6585 also notes 429 responses may be cached, so send explicit Cache-Control if that is not what you want.

What actually causes it

The most damaging pattern is self-inflicted: a client hits the limit, retries immediately, and the retries keep the counter pinned. Add a few dozen instances doing this in lockstep after a shared upstream blip and you have a herd that cannot recover on its own. The fix is exponential backoff with jitter — without randomisation every client wakes at the same moment and the stampede repeats on a slower cycle. Also common: a batch job that parallelises and trips a concurrency limit while its average rate looks harmless, and a shared API token used by several services so no team’s traffic explains the total.

How to debug it

Establish which limiter fired, then whether the server told you when to come back:

curl -sS -o /dev/null -D - https://api.example.com/v1/search \
| grep -i -E '^(HTTP/|retry-after|x-ratelimit|ratelimit|cf-ray|server):'

If Retry-After is present, honour it exactly; it is the only value the server has committed to. If only X-RateLimit-* headers appear, read Reset carefully — some APIs give a Unix timestamp and some give seconds remaining, and treating one as the other produces a hot loop or an hour-long stall. A CDN trace header on a 429 with no rate-limit headers points at an edge limit, which means the quota in the API docs is not the one you tripped.

Retry-After has two formats, and most clients only handle one

RFC 9110 §10.2.3 defines Retry-After as either delay-seconds, a plain integer, or an HTTP-date in IMF-fixdate form: Retry-After: 120 and Retry-After: Fri, 20 Jun 2026 18:30:00 GMT are both conformant on the same header. A client that only parses digits silently fails on the date form; the code above handles this correctly by checking wait.isdigit(), but plenty of hand-rolled retry logic in the wild does not, and treats a non-numeric value the same as a missing header, falling back to its own backoff schedule instead of the delay the server actually asked for.

The two forms are not interchangeable in what they depend on. Delay-seconds is self-contained: the client counts seconds from when it received the response, immune to any clock difference between client and server. An HTTP-date requires the client to compare it against its own clock, so a client running 30 seconds fast under-waits and one running slow over-waits; this is why servers that control both sides of the exchange generally prefer delay-seconds. Browser fetch() does not automatically retry on any status code, 429 included, so parsing Retry-After is entirely the calling code’s responsibility in a browser context. Server-side HTTP clients vary: Node’s undici implements Retry-After handling in its retry handler for a fixed set of status codes including 429, while a plain fetch() call anywhere gives you the header value and nothing more.

Test both formats against your own retry code deliberately; a client that only ever saw delay-seconds in staging will break the first time an upstream switches to an HTTP-date.

Working examples

curl

# Show status plus the headers that tell you when to retry.
curl -sS -o /dev/null -D - https://api.example.com/v1/search \
  | grep -i -E '^(HTTP/|retry-after|x-ratelimit|ratelimit):'

# --retry-all-errors covers 429, and curl honours Retry-After when present.
curl -sS --retry 5 --retry-all-errors --retry-max-time 120 \
  https://api.example.com/v1/search

Python (requests)

import random, time
import requests

def get_with_backoff(url, attempts=5):
    for attempt in range(attempts):
        resp = requests.get(url, timeout=10)
        if resp.status_code != 429:
            return resp
        # Retry-After wins; otherwise exponential backoff WITH jitter.
        wait = resp.headers.get("Retry-After", "")
        delay = float(wait) if wait.isdigit() else 2 ** attempt
        time.sleep(min(delay, 60) * (0.5 + random.random()))
    raise RuntimeError("still rate limited after retries")

print(get_with_backoff("https://api.example.com/v1/search").status_code)

Node (fetch)

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

async function getWithBackoff(url, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(url);
    if (res.status !== 429) return res;

    const header = res.headers.get("retry-after");
    const base = header && /^\d+$/.test(header) ? Number(header) : 2 ** i;
    // Jitter is not optional: without it every client retries in lockstep.
    await sleep(Math.min(base, 60) * 1000 * (0.5 + Math.random()));
  }
  throw new Error("still rate limited");
}

console.log((await getWithBackoff("https://api.example.com/v1/search")).status);

Reference and tooling

The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.

Other 4xx codes