HTTP 429 Too Many Requests: Meaning, Causes, and Fixes

HTTP 429 Too Many Requests means the client sent more requests than the server's rate limit allows in a given window. It is a client error about a policy limit, not a sign the server is broken. Stop sending, wait for the time in Retry-After, then retry. If that header is absent, back off before you try again.
429 sits in the 4xx group of HTTP status codes, the ones that put the problem on the client side. This page covers what the specification requires, which headers the response should carry, the causes, how a client should back off, and how to test the behavior without writing a test that fails at random.
What the RFC says about 429
RFC 6585 section 4 defines the code in one sentence: "The 429 status code indicates that the user has sent too many requests in a given amount of time ("rate limiting")."
The same section fills in the rest. The response should explain the condition, and it may include a Retry-After header saying how long to wait. What gets counted is the server's choice: one resource, a whole server, several servers, a set of credentials, or a cookie. Two APIs can both follow the rule and still count differently, so read the vendor's documentation before you assume a window.
The section also says caches must not store a 429. That matters if you run a CDN: a cached 429 keeps rejecting a client whose quota has already reset. And a 429 says the requester crossed a limit, not that the origin is failing. That is the line between 429 and 503.
What a 429 response must carry
Only one header is specific to 429, and it is optional. The rest are conventions, and the most useful set is still a draft, not a published standard.
| Header | Status | What it does |
|---|---|---|
Retry-After | Optional | Carries an HTTP date or a non-negative number of seconds. Defined in RFC 6585 section 4 and RFC 9110 section 10.2.3. |
RateLimit-Policy, RateLimit | Internet-Draft, not an RFC | draft-ietf-httpapi-ratelimit-headers-11, updated 23 May 2026. RateLimit-Policy carries the quota policy and RateLimit carries the remaining quota and window. Retry-After wins when both are present. The fields may also appear on 2xx responses so clients slow down before they are blocked. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | Older draft 6 shape | Still emitted by libraries. Express Rate Limit calls this shape draft-6. |
X-RateLimit-* | Vendor-specific | The meanings differ per vendor. GitHub uses them for the hourly maximum, the remaining count, and the reset time as UTC epoch seconds. |
Say draft when you mean draft: writing the RateLimit fields into a contract as a standard sets you up for a rename. Cache-Control: no-store is optional, but it makes the cache ban explicit to every proxy in the path.
Here is a complete response. The body uses problem details, the JSON error format defined by RFC 9457, in which type is a URI you choose for the class of problem and the other members describe this one occurrence. The header values follow draft 11 syntax.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Cache-Control: no-store
Retry-After: 60
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=60
{
"type": "https://api.example.com/problems/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Quota of 100 requests per 60 seconds exceeded. Retry after 60 seconds.",
"instance": "/problems/req-9c21"
}
The JSON member names above are this API's contract, not something RFC 6585 mandates. Keep the retry instruction in the header. A client should never have to read detail as prose to work out how long to wait.
HTTP 429 vs 503, 403, and 420
| Code | How it differs from 429 | Source |
|---|---|---|
| 503 Service Unavailable | The service itself is temporarily unable to handle requests, from overload or maintenance. It may also send Retry-After. A 429 is one client or one quota over a limit. A 503 is the whole service. | RFC 9110 section 15.6.4 |
| 403 Forbidden | The server understood the request and refuses it. Repeating it with the same credentials should not be automatic. A 429 is temporary and clears with time. | RFC 9110 section 15.5.4 |
| 420 Enhance Your Calm | An unofficial rate limit code from version 1 of the Twitter API. Twitter v1.1 and the current X documentation use 429 instead. | Unofficial status codes and X API response codes |
What causes HTTP 429 and how to fix it
| Cause | Fix |
|---|---|
| Tight polling, an unpaced loop, or a sudden batch job | Queue the work, batch the operations the API supports batching, cache stable reads, and pace the rest below the documented quota. |
| An immediate retry after every 429, which turns into a retry storm | Honor Retry-After. Without it, use bounded exponential backoff with jitter and a fixed cap on attempts. |
| Several workers or tokens drawing on one account quota | Hold the quota state in one shared limiter. Cloudflare states that its global API quota is cumulative across the dashboard, API keys, and API tokens. |
| Many users behind one egress IP, such as a VPN or NAT gateway, while the server keys its limit by IP | Prefer an authenticated identity as the limiter key wherever the service supports one. |
| Burst capacity too small, or a limiter key that covers too much traffic | Measure the steady rate and the burst you serve, then set replenish rate, burst capacity, and key resolver to match. |
| Abuse or an attack | Enforce at the edge. RFC 6585 notes that a server under attack may drop connections rather than answer every request. |
Sources for those rows: the Postman guide to 429, GitHub's rate limit documentation, Cloudflare's 429 page, the Spring Cloud Gateway reference, and RFC 6585 section 7.2.
Express
express-rate-limit sends Retry-After on every blocked request. Set standardHeaders to draft-8 and turn legacy headers off so clients see one set. The numbers below are an example policy, not a recommendation.
import { rateLimit } from "express-rate-limit";
const apiLimiter = rateLimit({
windowMs: 60_000,
limit: 100,
standardHeaders: "draft-8",
legacyHeaders: false,
});
app.use("/api", apiLimiter);
FastAPI
FastAPI has no built-in limiter. The usual choice is SlowAPI, a third-party library whose own documentation calls it alpha software, so treat it accordingly. The route decorator sits above the limit decorator, and the endpoint has to accept a Request argument or the limiter cannot read the caller.
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
app = FastAPI()
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.get("/items")
@limiter.limit("100/minute")
async def items(request: Request):
return {"items": []}
Spring Cloud Gateway
The RequestRateLimiter filter returns 429 by default. Three settings decide the behavior: a KeyResolver naming what is limited, a replenish rate for the steady allowance, and a burst capacity for the spike above it. Key by principal rather than IP when you have an authenticated caller.
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: api
uri: http://api-service
predicates:
- Path=/api/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
Cloudflare API
This one is a vendor claim, not a specification. Cloudflare states a global limit of 1,200 requests per five minutes per user, and says going over it blocks all API calls for the next five minutes. The quota is cumulative, so dashboard clicks, API key calls, and API token calls draw on the same allowance. Pace all three together and wait out the reset. Rotating tokens does not help, because the limit follows the user. Cloudflare says enterprise customers can ask Support to raise some limits.
How a client should handle a 429
Stop hitting the exhausted quota. Read
Retry-Afterand accept both forms it can take, a whole number of seconds or an HTTP date.Back off when the header is absent. Use bounded exponential backoff with jitter. Jitter means adding a random amount to each wait, so that a hundred clients blocked at the same instant do not all come back at the same instant.
Cap the attempts. GitHub tells clients to wait longer between each try and to give up with an error after a fixed number of retries. An uncapped loop turns one rate limit into a self-inflicted outage.
Retry a GET, think before retrying a POST. A GET is safe to repeat. A state-changing POST is not, unless the API supports an idempotency key, meaning a value that makes a repeated call count only once. HTTP methods covers which verbs are idempotent.
The same rules as one helper: both Retry-After formats, full jitter as the fallback, four retries.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function retryDelayMs(response, attempt) {
const value = response.headers.get("retry-after");
if (value && /^\d+$/.test(value)) return Number(value) * 1_000;
if (value) {
const retryAt = Date.parse(value);
if (Number.isFinite(retryAt)) return Math.max(0, retryAt - Date.now());
}
const backoffCap = Math.min(30_000, 1_000 * 2 ** attempt);
return Math.floor(Math.random() * backoffCap);
}
async function getWith429Retry(url, maxRetries = 4) {
for (let attempt = 0; ; attempt += 1) {
const response = await fetch(url);
if (response.status !== 429 || attempt === maxRetries) return response;
await sleep(retryDelayMs(response, attempt));
}
}
How to test for HTTP 429
The RFC leaves the counting scope to the server, so the test has to fix it. The setup that works: a test-only route limited to two requests per minute, keyed by a client ID unique to this run, called sequentially. The third call is then a 429 by construction, not by luck. The rest of API testing applies as usual.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
const clientId = "rate-limit-test-" + randomUUID();
const call = () =>
fetch("http://localhost:3000/test-only/two-per-minute", {
headers: { "x-test-client": clientId },
});
const first = await call();
const second = await call();
const limited = await call();
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(limited.status, 429);
const problem = await limited.json();
assert.equal(problem.status, 429);
assert.match(problem.type, /rate-limit-exceeded$/);
const retryAfter = limited.headers.get("retry-after");
assert.ok(retryAfter, "this API contract requires Retry-After");
assert.ok(
/^\d+$/.test(retryAfter) || Number.isFinite(Date.parse(retryAfter)),
"Retry-After must be seconds or an HTTP date",
);
One caveat. Retry-After is optional under RFC 6585, and the test demands it only because this API's contract promises it. If yours does not, drop that assertion and keep the grammar check for when the header is present.
The flaky version of this test looks reasonable and fails on a Tuesday. It fires many parallel requests at a shared staging limit and assumes request number N gets rejected. Other suites eat the same quota, a previous run has already spent part of it, fixed sleeps drift across the window boundary, and an exact countdown assertion goes stale the moment the limiter is retuned. The stable version pins a unique identity, a known low quota, sequential calls, and limiter state reset before the run, then asserts the status, the problem type, and the header grammar rather than a number. That is the general fix for flaky tests: replace timing and shared state with something you control.
How Qodex flags a 429
Qodex generates API tests from an OpenAPI spec, a Postman collection, or a chat brief, and runs them on every pull request. Each failure comes back classified as a real bug, a stale test with a proposed repair diff, or an environment issue, and every real bug carries the failing request and response. A scenario that expected 200 and got 429 arrives with the Retry-After and rate limit headers the API actually sent. See how Qodex API testing works.
Related status codes
HTTP 503 Service Unavailable. Overload and maintenance, service-wide.
HTTP 401 vs 403. Identity versus permission.
HTTP 422 Unprocessable Content. Valid syntax that still fails a rule.
HTTP 500 Internal Server Error. The generic server failure.
HTTP status codes. The full list, 1xx to 5xx.
API testing. What to assert and how to run it in CI.
Frequently Asked Questions
What does HTTP 429 Too Many Requests mean?
It means the client sent more requests than the server allows in a time window, as defined in RFC 6585 section 4. The request was not processed. Wait for the window to reset, then retry.
How do I fix an HTTP 429 error?
On the client, pace your requests, honor Retry-After, and share one limiter across all workers. On the server, size the burst and rate to real traffic and key the limit by identity rather than by IP address.
How long should I wait after a 429 response?
Exactly as long as Retry-After says, which is either a number of seconds or an HTTP date. If the header is absent, use exponential backoff with jitter and a cap on the maximum wait.
Why do I keep getting 429 errors?
Usually a shared quota. Several workers or tokens draw on one account limit, or many users sit behind one egress IP address. The other common cause is retry logic that fires again too quickly.
Is Retry-After required on a 429 response?
No. RFC 6585 section 4 says the response may include it, not that it must. Servers should send it because it removes the guesswork, and clients have to cope when it is missing.
Can a 429 response be cached?
No. RFC 6585 section 4 states that caches must not store a 429 response. Sending Cache-Control no-store alongside it makes that rule explicit to any proxy sitting in the request path.
Should a client retry a POST after HTTP 429?
Only when the API guarantees the replay is safe, for example through an idempotency key that makes a repeated call count once. A GET can be retried freely once the wait has elapsed.
What is the difference between HTTP 429 and 503?
A 429 means one client went over its limit. A 503 means the whole service is overloaded or in maintenance. Both may carry Retry-After, so read the status code before you read the header.




