Rate limits, 429 and Retry-After
How servers count requests, what the headers mean, and a client that waits as long as it's told.
A rate limitcaps how many requests a client may send in a period of time, such as 100 per minute. Servers use them to share capacity fairly, so one busy client can't slow everyone else down, and to protect themselves from mistakes like a loop that never stops. Almost every public API has one. When you go over it, the server answers 429 Too Many Requests. Unlike most errors, a 429 comes with instructions: it often tells you exactly how long to wait.
A real limiter
/api/RateLimitsign in to see allows 5 requests per 60 seconds per client. You name your client with an X-Client-Id header, any value you like; without it, the server goes by your IP address. Six requests in a row:
$ for i in 1 2 3 4 5 6; do
echo "--- request $i"
curl -s -i -H "X-Client-Id: rate-demo" https://api.testingapis.com/api/RateLimit | grep -i -E "^HTTP|^x-ratelimit|^retry-after"
done
--- request 1
HTTP/1.1 200 OK
x-ratelimit-limit: 5
x-ratelimit-remaining: 4
x-ratelimit-reset: 1791738225
--- request 2
HTTP/1.1 200 OK
x-ratelimit-limit: 5
x-ratelimit-remaining: 3
x-ratelimit-reset: 1791738225
...
--- request 5
HTTP/1.1 200 OK
x-ratelimit-limit: 5
x-ratelimit-remaining: 0
x-ratelimit-reset: 1791738225
--- request 6
HTTP/1.1 429 Too Many Requests
retry-after: 59
x-ratelimit-limit: 5
x-ratelimit-remaining: 0
x-ratelimit-reset: 1791738225The sixth answer's body says the same thing in words:
{"status":429,"error":"Too Many Requests","message":"Rate limit of 5 requests per 60s exceeded. Retry in 59s.","retryAfter":59}Four headers carry the information a client needs:
| Header | Meaning | Here |
|---|---|---|
X-RateLimit-Limit | How many requests the window allows | 5 |
X-RateLimit-Remaining | How many are left in the current window | Counts down from 4 to 0 |
X-RateLimit-Reset | When the window resets, as a Unix timestamp | 1791738225, which is 17:03:45 UTC |
Retry-After | How long to wait before trying again, sent with the 429 | 59 seconds |
A Unix timestamp counts seconds since 1 January 1970, UTC. To read one, multiply by 1000 and pass it to new Date() in JavaScript. The first request arrived at 17:02:45, which started a 60-second window ending at 17:03:45, and the sixth request, a second later, was told to wait 59 seconds.
Two more endpoints help when testing. One shows your window without using a request, the other resets it:
$ curl -s -H "X-Client-Id: rate-demo" https://api.testingapis.com/api/RateLimit/status
{"clientId":"id:rate-demo","used":5,"remaining":0,"limit":5,"resetsInSeconds":59}
$ curl -s -X POST -H "X-Client-Id: rate-demo" https://api.testingapis.com/api/RateLimit/reset
{"message":"Rate limit window reset.","clientId":"id:rate-demo"}Notice that usedis 5, though by then the client had sent seven requests. Refused requests don't count against the limit here. Not every API works that way; some keep counting, so a client that hammers away during a 429 stays locked out longer.
What's standard and what isn't
429is defined in RFC 6585, which added a handful of status codes to HTTP. It says the client sent too many requests in a given time, and allows aRetry-Afterheader.Retry-Afteris defined in RFC 9110 and has two forms: a number of seconds (120) or an HTTP date (Sun, 11 Oct 2026 17:03:45 GMT). A client has to handle both. Servers also send it with 503, to say how long they expect to be unavailable.- The
X-RateLimit-*headers are a convention, not a standard. Their names and meanings vary: some APIs send the reset as a timestamp, others as seconds from now. An IETF draft proposes standardRateLimitheader fields; until that settles, read each API's documentation.
How servers count
"5 per minute" can mean different things depending on how the server counts. Three methods are common:
limit: 5 requests per minute
fixed window | 12:00:00 ─────────────── 12:01:00 | 12:01:00 ─────────────── 12:02:00 |
counts reset at the start of each minute
sliding window the limit applies to any 60 seconds: "the last minute", measured from now
token bucket a bucket holds up to 5 tokens; each request takes one;
one token is added back every 12 seconds- A fixed windowcounts requests in blocks of time that start at set moments, and resets the count at the start of each block. It's the simplest, and it's what this API uses, with the window starting at your first request.
- A sliding windowlooks back over the last 60 seconds from the current moment, so there's no reset to wait for: old requests drop out one by one.
- A token bucket gives each client a bucket of tokens. Each request spends one, and tokens are added back at a steady rate. Clients can send a short burst, up to the bucket size, and are then held to the refill rate.
The fixed window has a known weak spot at the edge between two windows:
12:00:58 ● ● ● ● ● 5 requests, the end of the first window
12:01:00 ● ● ● ● ● 5 more, a new window has started
└────────┘
10 requests in 2 seconds, and none of them refusedTwice the limit got through in two seconds. That's why some APIs choose the other methods, and why your client shouldn't assume that "5 per minute" means "one every 12 seconds is fine" or that bursts will always be refused.
Handling a 429 in a client
src/rate-limit.mjs reads what the server says. retryAfterMs turns either form of Retry-After into milliseconds; readRateLimit collects the three X-RateLimit headers; pauseBeforeNextsays how long to wait so the next request doesn't hit the limit at all:
// Reading what a server says about its rate limit.
/**
* Retry-After in milliseconds, or null if the header is missing or unreadable.
* The header is either a number of seconds ("120") or an HTTP date ("Sun, 11 Oct 2026 16:00:00 GMT").
*/
export function retryAfterMs(value, now = Date.now()) {
if (value == null || value.trim() === "") return null;
if (/^\d+$/.test(value.trim())) return Number(value) * 1000;
const date = Date.parse(value);
if (Number.isNaN(date)) return null;
return Math.max(0, date - now);
}
/**
* The X-RateLimit-* headers many APIs send. They aren't a standard, so names and meanings vary between APIs;
* these are the ones this API uses. Returns null when the headers aren't there.
*/
export function readRateLimit(headers) {
const limit = headers.get("x-ratelimit-limit");
const remaining = headers.get("x-ratelimit-remaining");
const reset = headers.get("x-ratelimit-reset");
if (limit == null || remaining == null) return null;
return {
limit: Number(limit),
remaining: Number(remaining),
resetsAt: reset == null ? null : new Date(Number(reset) * 1000),
};
}
/** How long to pause before the next request so it doesn't hit the limit: 0 while requests remain. */
export function pauseBeforeNext(rateLimit, now = Date.now()) {
if (!rateLimit || rateLimit.remaining > 0 || !rateLimit.resetsAt) return 0;
return Math.max(0, rateLimit.resetsAt.getTime() - now);
}Three habits make a client behave well:
1. Wait as long as you're told
withRetries from lesson 3 already does this: if a failed response has Retry-After, that replaces its own backoff. The server knows when your window resets; your backoff formula is only a guess. The test for both forms of the header:
test("Retry-After comes as seconds or as a date", () => {
const now = Date.parse("Sun, 11 Oct 2026 16:00:00 GMT");
assert.equal(retryAfterMs("2"), 2000);
assert.equal(retryAfterMs("Sun, 11 Oct 2026 16:00:10 GMT", now), 10_000);
assert.equal(retryAfterMs("Sun, 11 Oct 2026 15:59:00 GMT", now), 0); // already passed: go now
assert.equal(retryAfterMs("soon"), null); // unreadable: fall back to our own backoff
assert.equal(retryAfterMs(null), null);
});Each assertion covers one case: seconds, a date in the future, a date already past (go now), and two values that can't be read, where the client falls back to its own backoff.
2. Give up when the wait is too long
The limiter above asked for 59 seconds. If a person is waiting for a page, sleeping for a minute and then answering is worse than saying "busy, try again shortly" straight away. withRetries takes a maxWaitMs, 30 seconds by default, and returns the 429 at once when the server asks for longer:
test("a client that won't wait that long gives up at once", async () => {
const started = Date.now();
const response = await withRetries(hit, { maxWaitMs: 5000 });
assert.equal(response.status, 429);
assert.ok(Date.now() - started < 3000);
});A background job, with no one waiting, can afford a much larger limit. Choose it per caller, not once for the whole application.
3. Slow down before you hit the limit
A 429 means you've already wasted a request. A client that reads X-RateLimit-Remainingcan avoid that: when it reaches 0, wait until the reset time before sending more. That's pauseBeforeNext:
test("pause before the next request only when none remain", () => {
const now = Date.parse("Sun, 11 Oct 2026 17:03:00 GMT");
const headers = (remaining) =>
new Headers({ "X-RateLimit-Limit": "5", "X-RateLimit-Remaining": String(remaining), "X-RateLimit-Reset": "1791738225" });
assert.equal(pauseBeforeNext(readRateLimit(headers(2)), now), 0); // requests left: go ahead
assert.equal(pauseBeforeNext(readRateLimit(headers(0)), now), 45_000); // none left: wait for 17:03:45
assert.equal(pauseBeforeNext(readRateLimit(new Headers()), now), 0); // no headers: nothing to go on
});With requests remaining it returns 0. With none left it returns the time until 17:03:45, which is 45 seconds after the made-up "now" of 17:03:00. Without the headers it returns 0, because it has nothing to go on, and the client falls back to handling the 429 if one comes.
Testing against a rate limit
The live test sends five requests, checks the countdown, then checks the sixth is refused:
test("five requests count down, the sixth gets 429 and Retry-After", async () => {
for (let expected = 4; expected >= 0; expected--) {
const response = await hit();
assert.equal(response.status, 200);
assert.equal(readRateLimit(response.headers).remaining, expected);
}
const limited = await hit();
assert.equal(limited.status, 429);
const wait = retryAfterMs(limited.headers.get("retry-after"));
assert.ok(wait > 0 && wait <= 60_000, `Retry-After was ${wait} ms`);
});The file gives its client an ID no one else uses, built from the process ID and the time, and resets the window in an afterhook. Rate-limit tests that share an ID, with each other or with other people, fail depending on who ran first. When you test a server's own rate limiting rather than a client, the reliability lesson in the REST tutorial lists the server-side checks.
Check that the headers mean something
When this lesson was first written, every other endpoint of this API also sent rate-limit headers, and they never changed: always 1000 and 999, with a reset time in July 2024. A client that trusted them would have believed it had 999 requests left forever. They have since been removed, and only /api/RateLimitsign in to see, which really limits, sends them. The habit is the point: before writing logic around an API's rate-limit headers, make a few requests and check that Remaining really goes down.
Check your understanding
1.A 429 arrives with Retry-After: Sun, 11 Oct 2026 17:05:00 GMT, and it's 17:04:30. How long should the client wait?Show answer
30 seconds. Retry-After can be a date instead of a number of seconds, and the client has to handle both.
2.Remaining is 0 and Reset is 40 seconds away. What should a well-behaved client do with its next request?Show answer
Wait about 40 seconds before sending it, or tell the caller the API is busy if that's too long. Sending it now only earns a 429.
3.A server allows 10 requests per minute in fixed windows. What's the most it could accept in 2 seconds?Show answer
20: ten at the very end of one window and ten at the very start of the next.
4.Why is it safe to retry a POST that got a 429?Show answer
The server refused the request because of the limit, so it didn't act on it. Lesson 3 relies on this.
Exercise
Write a script that needs to make 12 requests to /api/RateLimitsign in to see with its own X-Client-Id, and uses readRateLimit and pauseBeforeNext so that it never receives a 429. Print the time of each request. How long does the whole run take, and why? Reset the window when it finishes. Then compare with a version that ignores the headers and relies on withRetries with a maxWaitMs of 70 seconds.