Skip to content
Control Plane Labs

429 Too Many Requests

Client error response defined by RFC 6585 §4.

Last updated July 27, 2026

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.

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