API 429 Rate Limit — Retry-After Header Done Right
Your retry helper just turned a soft rate limit into a hard ban. GitHub returned 429 with Retry-After: 42. Your client slept 2 ** attempt seconds, fired again at second 8, and burned the remaining quota. Stripe and OpenAI do the same dance with slightly different headers. The bug is almost never “backoff is too aggressive” — it is “you ignored the server’s schedule.”
What 429 actually means
429 Too Many Requests is a contract, not a suggestion. The response body may say rate_limit_exceeded. The useful signal is usually in headers:
| Header | Who uses it | Meaning |
|---|---|---|
Retry-After | GitHub, many gateways | Seconds (or HTTP-date) until next attempt is allowed |
X-RateLimit-Remaining | GitHub, Discord | How many calls left in the window |
x-ratelimit-reset-tokens / x-ratelimit-reset-requests | OpenAI | Separate meters for tokens vs requests |
If you only parse the status code and invent your own sleep, you are negotiating against a clock you cannot see.
Parse Retry-After correctly
function retryAfterMs(res: Response): number | null {
const raw = res.headers.get("retry-after");
if (!raw) return null;
const asInt = Number(raw);
if (!Number.isNaN(asInt)) return asInt * 1000;
const date = Date.parse(raw);
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
return null;
}
Integer form is seconds. HTTP-date form is absolute. Mixing them up (treating a date string as seconds) produces multi-year sleeps or immediate retries. Log both the raw header and the computed delay once — that single log line ends half the “flaky client” threads.
Prefer the header, then fall back to jittered backoff
async function fetchWithLimit(url: string, init?: RequestInit, max = 5) {
let attempt = 0;
for (;;) {
const res = await fetch(url, init);
if (res.status !== 429 && res.status !== 503) return res;
attempt++;
if (attempt > max) throw new Error(`rate limited after ${max} tries`);
const fromHeader = retryAfterMs(res);
const exp = Math.min(60_000, 500 * 2 ** attempt);
const jitter = Math.floor(Math.random() * 250);
const wait = (fromHeader ?? exp) + jitter;
await new Promise((r) => setTimeout(r, wait));
}
}
Rules that keep production calm:
- Honor
Retry-Afterwhen present. Cap it (e.g. 5 minutes) so a misconfigured gateway cannot freeze a worker forever. - Add jitter even when the header is exact. Synchronized clients wake together and stampede.
- Do not retry POST/PATCH blindly. Idempotency keys (Stripe) or safe GETs only unless your API documents otherwise.
- Stop the loop. Infinite retry on 429 is how you turn a shared IP into a permanent blocklist entry.
Failure modes people miss
Secondary rate limits. GitHub has primary REST quotas and “abuse” secondary limits that trigger on concurrent bursts. Slowing one request path is not enough if you fan out 50 parallel jobs.
Token vs request meters. OpenAI can accept the request count but reject because the prompt ate the token budget. Retrying the same oversized prompt will keep failing — trim input, do not just sleep.
Shared quotas across processes. Three Kubernetes pods with identical API keys share one bucket. Per-process exponential backoff looks fine in staging (one pod) and collapses in prod. Put a distributed limiter (Redis token bucket) in front of the SDK.
Circuit breaker after N 429s. After three consecutive 429s for the same key, open the circuit for the reset window. Returning a fast failure to the user beats melting the dependency and your own thread pool.
Debugging checklist
- Capture status, all rate-limit headers, and request id from the failing response.
- Confirm whether the limit is per-user, per-IP, or per-API-key.
- Measure concurrency:
Promise.allof 100 calls is often the real culprit. - Verify clock skew if
Retry-Afteris an HTTP-date (NTP drift makes “wait until T” wrong). - Check whether a proxy stripped
Retry-Afterbefore it reached your client.
Respect the header first. Invent backoff second. Cap everything. That order is the difference between a polite client and a banned one.