A 429 means the server understood the request but refused it because a rate, quota, or concurrency limit was exceeded. Honor Retry-After when present, otherwise use capped exponential backoff with jitter, retry only idempotent requests or POSTs with an idempotency key, and identify whether the limit came from the origin, a gateway, or a CDN.
- 429 is a policy refusal, not a malformed request or a server crash, so do not handle it like a 400 or a 500.
- Retry-After is either delta-seconds or an HTTP-date; compare dates with the response Date header and clamp the result before sleeping.
- Rate limits cover a short window, quotas cover a long allowance, concurrency caps in-flight work, and circuit breakers are a client-side failure response.
- Full-jitter exponential backoff with a per-attempt cap, an overall deadline, and a retry budget prevents synchronized retry storms.
- Retry POST only with an idempotency key, and find out whether the 429 came from the origin, an API gateway, or a CDN before changing code.
429 Too Many Requests is an unusual HTTP error: the request is usually well formed, the credentials may be valid, and the service may be healthy. A policy layer decided that this client has used its allowance and must wait. That makes 429 easy to misread as a temporary glitch and easy to make worse with an immediate retry.
This guide covers what the status code actually promises, how to read both forms of Retry-After, how rate limits differ from quotas and concurrency caps, and how to build a retry loop that does not turn one busy minute into a retry storm.
What 429 actually signals
429 was added by RFC 6585 and means the client sent too many requests in a given amount of time. The server understood the request; it chose not to process it because a limit was crossed. That distinction matters:
- A
400says the request is malformed. Repeating it changes nothing. - A
401or403says identity or permission is wrong. Fix credentials or scopes. - A
503says the service cannot handle the request right now, often because of overload or maintenance, and may also carryRetry-After. - A
429says a policy exists and this request exceeded it.
The limit can be requests per second, requests per day, concurrent requests, compute cost, or an edge security rule. The status code does not say which one. RFC 6585 recommends a response body that explains the condition, but it is not required, and many CDNs return an HTML page instead of JSON.
HTTP/1.1 429 Too Many Requests
Date: Wed, 11 Nov 2026 07:29:00 GMT
Content-Type: application/problem+json
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1794382170
Cache-Control: no-store
X-Request-Id: req_01JCH6X8Y4Q2V7M3N9K5R1T6B8
{
"type": "https://api.example.com/problems/rate-limited",
"title": "Too Many Requests",
"status": 429,
"code": "rate_limit_exceeded",
"detail": "100 requests per 60 seconds exceeded for key acct_8241.",
"retryAfter": 30
}
The status line confirms the error class; Retry-After: 30 is the only standard retry signal in the response. The X-RateLimit-* headers and the JSON body are vendor conventions, and their units differ between APIs.
A 429 is retryable in principle, not automatically. The wait may be milliseconds or until the next billing period. Use the HTTP status lookup when you need the standard semantics, then read the headers and body for the actual cause.
Retry-After: delta-seconds versus HTTP-date
RFC 9110 defines the standard Retry-After header as either an integer number of seconds or an HTTP-date:
Retry-After: 120means wait 120 seconds from when the response was generated.Retry-After: Wed, 11 Nov 2026 07:30:00 GMTmeans do not retry before that absolute time.
Servers should send the IMF-fixdate form shown above. HTTP parsers must also accept the obsolete RFC 850 and asctime formats, but new servers should not produce them.
HTTP/1.1 429 Too Many Requests
Date: Wed, 11 Nov 2026 07:29:00 GMT
Retry-After: Wed, 11 Nov 2026 07:30:00 GMT
Content-Type: application/json
RateLimit: "default";r=0;t=60
RateLimit-Policy: "default";q=100;w=60
Cache-Control: no-store
{"error":"rate_limited","message":"Retry after the date in Retry-After."}
The date form is where most client bugs live. Do not compare the date to the user's clock. Compare it to the response's Date header with delay = max(0, Date.parse(retryAfter) - Date.parse(date)). If Date is missing, fall back to the local clock and accept that skew may make the wait too short or too long. A date in the past means "retry now", but adding a small jitter still helps when many clients received the same response.
Treat Retry-After as advice, not a contract. The limit may still be active when the timer fires, so the next response can be another 429. If the header is absent, do not invent a one-second delay; use exponential backoff. If it is present, honor it as a floor and add jitter. If it is absurdly large, such as a quota reset in six hours, do not block a worker thread for six hours: persist the job and schedule it, or fail the user-facing request with a clear message.
Two unit traps are common: Retry-After is seconds, not milliseconds, and a vendor's X-RateLimit-Reset may be epoch seconds, delta seconds, or milliseconds. Check each API's documentation.
Rate limits, quotas, concurrency limits, and circuit breaking
These controls all produce "slow down" behavior, but they recover on different clocks.
| Control | What it caps | Typical horizon | What a 429 means | Correct response |
|---|---|---|---|---|
| Rate limit | Requests or cost per key | Seconds or minutes | The short-window budget is exhausted | Wait for the reset, honor Retry-After, add jitter |
| Quota | Total usage or spend | Day, month, or plan period | The long-lived allowance is gone | Stop until the reset or an upgrade; retrying now is wasted |
| Concurrency limit | Simultaneous in-flight requests | Instantaneous | Too many requests are open at once | Reduce parallelism; retry when a slot frees |
| Circuit breaker | Calls to an unhealthy dependency | Until the next probe | Not a server limit at all | Fail fast, probe later, do not hammer |
A rate limit is usually a token bucket with a capacity and a refill rate. It allows a short burst, then smooths traffic. A fixed-window counter is weaker: a client can send a full window at 59.9 seconds and another full window at 60.1 seconds, so sliding windows, sliding logs, or GCRA give fairer enforcement.
A quota is a long-horizon entitlement: requests per day, tokens per month, or a plan allowance. When a quota is exhausted, waiting thirty seconds accomplishes nothing. If an API uses 429 for quota exhaustion, the body should say so with a code such as quota_exceeded, and the reset may be hours away. Distinguish a plan limit that requires an upgrade from a period allowance that will refill.
A concurrency limit caps the number of requests in flight. The 429 clears as soon as one in-flight request finishes, so the real fix is bounded parallelism and a queue, not a long exponential sleep per request.
A circuit breaker is a client-side or proxy pattern: when the error rate or latency crosses a threshold, the breaker opens and calls fail fast until a half-open probe succeeds. A 429 can feed the breaker, but a routine rate limit should throttle traffic, not trip the breaker as if the dependency were down. Keep separate breakers for 5xx and timeouts versus 429, and scope them per host and key.
Parsing the 429 response safely
The status code tells you the class; the headers and body tell you what to do. Parse them defensively:
- Check
response.status === 429first. Do not search the body text for "rate limit". - Header names are case-insensitive. Use
response.headers.get("retry-after"), not an object property with an assumed casing. - Treat header values as untrusted input. Clamp the result to
[0, cap], rejectNaN, and remember that Node'ssetTimeouttreats delays above 2,147,483,647 milliseconds (about 24.8 days) as 1 millisecond. A quota reset can overflow it. Headers.get()joins repeated headers with commas. Split on commas and take the first valid token.- Check
Content-Typebefore parsing. A CDN may return an HTML page with a 429 status, andJSON.parsewill throw. Cap the body read at something like 64 KB so a giant error page cannot exhaust memory. - Collect identifiers:
x-request-id,cf-ray,x-amzn-requestid,x-envoy-ratelimited,server,via, andx-cache. They tell you which layer answered. - Read limit headers only when the API documents them.
X-RateLimit-*is a convention;RateLimitandRateLimit-Policycome from an IETF draft. Values may be integers, epoch seconds, durations such as6m0s, or structured fields. - Do not log API keys or cookies. Hash the limiter key if you need to correlate events.
- If you plan to retry, release the response body with
response.body?.cancel()so the connection can be reused. A tight retry loop that holds every body can exhaust the connection pool.
Exponential backoff with jitter and a cap
Naive backoff is base × 2^attempt. Without jitter, every client that received a 429 at the same moment retries at the same moment. If ten thousand clients back off for exactly two seconds, they all hit the API at second two and the limiter rejects them again. That is a retry storm.
Jitter options:
- Full jitter:
random(0, min(cap, base × 2^attempt)). Maximum spread, slightly longer average wait. - Equal jitter: half the delay fixed, half random. More predictable, less spread.
- Decorrelated jitter:
min(cap, random(base, previous × 3)). Good for long-lived workers.
Add four bounds:
- A per-attempt cap, such as 30 seconds, so
2^10does not become hours. - A total deadline, such as two minutes, across all attempts rather than per attempt.
- A maximum attempt count, such as five. More attempts rarely help once a quota is exhausted.
- A retry budget, such as retries limited to 10% of requests, so a retry loop cannot double traffic during an outage.
When Retry-After exists, use it as a floor and then add jitter. When it does not, use full jitter. A simple, effective rule is delay = min(cap, retryAfter + random(0, 1s)) when the header exists, otherwise delay = random(0, min(cap, base × 2^attempt)).
The algorithm below also checks idempotency, parses both header forms against the server clock, releases the response body, and stops at a deadline.
const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
function parseRetryAfter(response: Response, capMs: number): number | null {
const raw = response.headers.get("retry-after")?.split(",")[0]?.trim();
if (!raw) return null;
let ms: number;
if (/^\d+$/.test(raw)) {
ms = Number(raw) * 1000; // delta-seconds, never milliseconds
} else {
const target = Date.parse(raw);
if (Number.isNaN(target)) return null; // unknown format: fall back to backoff
const serverNow = Date.parse(response.headers.get("date") ?? "") || Date.now();
ms = target - serverNow; // use the server clock to survive client skew
}
return Math.min(Math.max(ms, 0), capMs);
}
async function fetchWithRetry(
url: string,
init: RequestInit = {},
opts = { attempts: 5, baseMs: 500, capMs: 30_000, deadlineMs: 120_000 }
): Promise<Response> {
const headers = new Headers(init.headers);
const method = (init.method ?? "GET").toUpperCase();
const canRepeat = IDEMPOTENT_METHODS.has(method) || headers.has("idempotency-key");
const started = Date.now();
for (let attempt = 0; ; attempt += 1) {
const response = await fetch(url, init);
const retryable = response.status === 429 || response.status === 503;
if (!retryable || !canRepeat || attempt >= opts.attempts) return response;
const serverDelay = parseRetryAfter(response, opts.capMs);
const backoff = Math.min(opts.capMs, opts.baseMs * 2 ** attempt);
const fullJitter = Math.random() * backoff;
const delay =
serverDelay == null
? fullJitter
: Math.min(opts.capMs, serverDelay + Math.random() * 1000);
if (Date.now() - started + delay > opts.deadlineMs) return response;
await response.body?.cancel(); // release the socket before sleeping
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
The function returns the final Response rather than throwing, so the caller can read the error body on the last attempt. A production client should also share the backoff state per API key and enforce a retry budget, which are easier to add around this core than inside it.
Idempotency and retry safety
An HTTP 429 retry is safe only when the operation is repeatable. GET and HEAD are safe: they do not change server state, so retrying them is fine. PUT and DELETE are idempotent by definition: repeating the same request should produce the same state. POST and most PATCH requests are not. Retrying POST /orders or POST /payments can create two orders or charge a card twice.
A 429 is usually produced before the request is processed, which makes retrying a POST tempting. Do not rely on that. The 429 may come from an edge after the request was counted, or the client may see a timeout after partial processing. The only safe rule is to make the operation repeatable.
Use an Idempotency-Key header: generate a UUID per logical operation, send the same key on every retry, and have the server store the key with the final status and body for a fixed retention period, commonly 24 hours. The server should reject the same key with a different payload, usually with 422 or 409, and should handle concurrent requests with the same key by returning a conflict or the original result. Stripe popularized this pattern, and an IETF draft now documents it.
If the server does not support idempotency keys, do not auto-retry non-idempotent requests. Surface the failure and let the user or a reconciliation job check whether the operation succeeded.
One subtle case: a retry can return 409 Conflict because the first attempt actually succeeded and the key is still being processed. A client that treats every 409 as failure may retry again or show an error for a successful order.
Server-side and client-side behavior
If you run the API, the most important decision is the limit key. Per-IP limits are easy, but a corporate NAT or mobile carrier can put thousands of users behind one address, so one noisy client blocks everyone. Prefer account or API key limits, with IP limits only as an abuse backstop. Weight endpoints by cost: a GraphQL query or bulk export should not count the same as a health check.
Use a token bucket or GCRA rather than a fixed-window counter. A bucket with capacity 100 and a refill of 100 per minute allows a short burst and then smooths to the refill rate; a fixed window allows 200 requests around the boundary. Return 429 before doing expensive work, include Retry-After computed from the actual reset, and add a stable machine-readable code such as rate_limit_exceeded versus quota_exceeded. Set Cache-Control: no-store so an intermediary cannot cache the rejection. Do not use 429 for invalid credentials (401), missing permission (403), or validation (422).
If you call the API, rate-limit yourself before the server does. A client-side token bucket sized at 80% of the published limit, plus reading Remaining when it is available, prevents most 429s. Keep one retry owner: if the application, the SDK, the HTTP client, and the gateway all retry, five attempts become five times five times five. Use a shared gate per API key so 100 workers do not each sleep and retry independently. For interactive requests, fail fast with a useful message; for background jobs, queue and retry with a deadline. The HTTP request workbench is useful for checking how a specific method and header set behaves before you encode the retry policy.
CDNs, WAFs, and gateways produce their own 429s
Not every 429 comes from your application. A request can be rejected by Cloudflare, an AWS API Gateway, an Envoy rate-limit filter, or nginx before the origin sees it:
- Cloudflare rate-limiting rules return 429 and usually add
server: cloudflareandcf-ray. Some bot defenses return an HTML page rather than JSON and may omitRetry-After. - AWS API Gateway throttling returns 429 with
x-amzn-RequestIdand, depending on the error,x-amzn-ErrorType: TooManyRequestsException. - Envoy's local rate-limit filter adds
x-envoy-ratelimited: trueand may attachRetry-After. - nginx
limit_reqreturns 503 by default. If you setlimit_req_status 429, the same rejection appears as 429. A 503 with no application log can still be a rate limit. - HAProxy, Kong, and other gateways have their own defaults; check which component owns the response.
This matters for debugging: the origin access log may show nothing, so "the API returned 429" is misleading. Compare edge logs, load-balancer logs, and application logs using the request ID. Check whether the limit is keyed by IP, account, path, or a WAF rule, and whether the rule is a security control rather than a usage limit. A shared NAT can make a per-IP edge rule fire for unrelated users.
Also check caching. A 429 that gets cached by a CDN can be served to other clients after the limit has cleared. Origin and edge responses should both send Cache-Control: no-store for 429s.
Observability: Retry-After is standard, X-RateLimit is not
Retry-After is defined by HTTP and has a stable meaning. X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset are conventions with no standard semantics: reset may be epoch seconds, delta seconds, or a duration; the limit may count requests, tokens, or cost. GitHub uses x-ratelimit-reset in epoch seconds and adds x-ratelimit-resource. OpenAI uses x-ratelimit-reset-requests with values such as 1s and 6m0s. The IETF RateLimit and RateLimit-Policy drafts use structured fields and have changed across versions. Parse only the headers the specific vendor documents, and feature-detect per host instead of assuming every rate limit API uses the same names or units.
Instrument at least:
- 429 count by route, key type, and emitter (edge versus origin).
- Retry attempts per logical request, the chosen delay, and whether the retry succeeded.
Retry-Afterdistribution, including missing values.- Retry budget consumption and queue depth for background work.
- Rate-limit remaining and reset time when the API exposes them.
Log the attempt number, the raw Retry-After, the computed delay, the request ID, and the edge ray ID. Never log the API key; hash the limiter key if you need correlation. In traces, represent each retry as an event on one span so a single trace shows the whole backoff sequence rather than five disconnected requests. Alert on sustained 429 ratios and on retry volume growing faster than request volume; a single 429 is noise, a rising retry-to-request ratio is a storm forming.
A debugging workflow, and the mistakes to avoid
Work through these steps in order:
- Identify the emitter. Look at
server,cf-ray,x-amzn-RequestId,x-envoy-ratelimited,via, and the body content type. If the origin log has no matching request ID, the rejection happened at the edge. - Reproduce outside the application. Send the same method, URL, headers, and body with curl. If you copied the request from a browser, convert it with the cURL converter so quoting and header casing survive. A reproduction that omits
Authorizationor the idempotency key tests a different request. - Classify the limit. Read the body code and any documented headers: rate window, quota, concurrency, or security rule. This decides whether the fix is waiting, reducing parallelism, upgrading a plan, or changing a WAF rule.
- Check the scope. Is it one API key, one IP, one route, or every client? Compare with a second key and a different network. A single-key 429 is usually a usage problem; an all-key 429 is often an outage or a misconfigured limit.
- Check time. Compare
Retry-Afterwith theDateheader, verify whether reset values are seconds or milliseconds, and look for fixed-window bursts at the minute boundary. Client clock skew can make a date-formRetry-Afterlook expired. - Inspect retry behavior. Count attempts per logical request, list every layer that retries, and check whether jitter is actually random. A constant delay or a shared retry timer recreates the burst.
- Fix the right layer. Client: limiter, jitter, budget, idempotency. Server: key choice, response headers, quota messaging. Edge: rule scope and cache policy. Do not "fix" a CDN 429 by adding retries in the application.
- Verify. Watch the 429 ratio, retry success rate, p95 latency, and duplicate-write rate. A drop in 429s accompanied by a rise in 5xxs means you moved the bottleneck rather than removed it.
Common mistakes:
- Retrying immediately, or with a fixed one-second delay.
- Sleeping exactly
Retry-Afterwith no jitter and no shared gate. - Reading
Retry-After: 30as 30 milliseconds. - Parsing the human-readable error message instead of the status and headers.
- Retrying POST without an idempotency key.
- Retrying a quota error every few seconds until the next month.
- Retrying every 4xx, including 400 and 422, which cannot succeed unchanged.
- Retrying in the application, the SDK, and the gateway at the same time.
- Retrying forever with no attempt limit and no total deadline.
- Assuming
X-RateLimit-Resetmeans the same unit on every API. - Returning 429 for authentication failures, or omitting
Retry-Afterwhen the reset is known. - Caching a 429 response and serving it after the limit has cleared.
The status code is only the first signal. The delay, the scope, and the emitter decide whether a retry helps or makes the incident worse.
Frequently asked questions
Does a 429 mean I should retry the same request?
Usually yes, but only after the server's requested delay and only when the operation is safe to repeat. If Retry-After is absent, use jittered exponential backoff with a cap and a deadline rather than an immediate retry.
What is the difference between Retry-After seconds and an HTTP-date?
A value like 30 is delta-seconds measured from when the response was generated. A value like Wed, 11 Nov 2026 07:30:00 GMT is an absolute HTTP-date; subtract the response Date header, not your local clock, to get the wait.
Are X-RateLimit headers standardized?
No. X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset are de facto conventions with vendor-specific meanings and reset units. The IETF RateLimit fields are still drafts, so parse only the headers the specific API documents.
How do I retry a POST after a 429 without creating duplicates?
Send an Idempotency-Key header with a unique value for the logical operation and have the server store the key and response for a defined retention period. Without server-side support, surface the failure instead of retrying blindly.
Why do I get 429s that never appear in my application logs?
A CDN, WAF, API gateway, or reverse proxy can reject the request before it reaches the origin. Check response headers such as Server, cf-ray, x-amzn-RequestId, and x-envoy-ratelimited, then correlate with edge or load-balancer logs by request ID.