Parsing the Retry-After Header
When a server answers a rate-limited request with 429 Too Many Requests, the only reliable signal for when to retry lives in the Retry-After header โ and parsing it correctly is the foundation of every well-behaved client in the frontend resilience and UX handling area. The header looks trivial until you discover it ships in two incompatible syntaxes, that the value is attacker-influenced data you must sanitize, and that roughly half the rate-limited responses on the public internet omit it entirely. A client that trusts the header blindly will happily sleep for 31536000 seconds because a misconfigured upstream sent Retry-After: 31536000.
This guide specifies a robust parsing layer: the two wire formats, the relationship to RateLimit-Reset, how to clamp and sanitize untrusted input, what to do when the header is absent, and where jitter belongs in the pipeline.
The two wire formats
RFC 9110 ยง10.2.3 defines Retry-After with exactly two productions, and a conformant client must accept both:
HTTP/1.1 429 Too Many Requests
Retry-After: 120
HTTP/1.1 429 Too Many Requests
Retry-After: Fri, 20 Jun 2026 18:30:00 GMT
- delta-seconds โ a non-negative integer number of seconds to wait. Self-contained, immune to clock skew, and the form you should prefer when you control the server.
- HTTP-date โ an absolute IMF-fixdate timestamp. To turn it into a delay the client must subtract its own
Date.now(), which makes the computed wait sensitive to clock skew between client and server. A client clock running 30 s fast will under-wait; one running slow will over-wait.
The detailed parser that handles both forms, caps the result, and returns milliseconds-until-retry lives on parsing Retry-After: HTTP-date vs seconds.
RateLimit-Reset and the response contract
Retry-After is not the only timing signal. The IETF RateLimit header family (and the older X-RateLimit-* convention) carries a RateLimit-Reset value โ seconds until the current window refills โ that doubles as a fallback when Retry-After is missing. A complete client reads, in priority order:
| Header | Meaning | Form | Use as |
|---|---|---|---|
Retry-After |
Wait this long before retrying | delta-seconds or HTTP-date | Primary retry delay |
RateLimit-Reset |
Seconds until window reset | delta-seconds (draft) | Fallback delay |
X-RateLimit-Reset |
Window reset (legacy) | delta-seconds or epoch seconds | Fallback delay; sniff the magnitude |
RateLimit-Remaining |
Requests left in window | integer | Proactive throttle, not retry timing |
X-RateLimit-Reset is the ambiguous one: some servers emit seconds remaining (e.g. 30), others emit an absolute Unix epoch (e.g. 1781030400). Sniff the magnitude โ a value larger than, say, 10^9 is almost certainly an epoch timestamp and must be differenced against Date.now()/1000.
Configuration reference
A parser is only safe if its bounds are explicit. These are the knobs every production implementation should expose:
| Param | Type | Default | Range | Effect |
|---|---|---|---|---|
maxDelayMs |
number | 300000 (5 min) |
1 000 โ 3 600 000 | Hard ceiling; clamps hostile/oversized values |
defaultDelayMs |
number | 1000 |
250 โ 60 000 | Used when no header is present |
minDelayMs |
number | 0 |
0 โ 5 000 | Floor; avoids hot-loop retries on Retry-After: 0 |
jitterRatio |
number | 0.2 |
0 โ 1 | Fraction of the delay randomized to de-correlate clients |
clockSkewToleranceMs |
number | 2000 |
0 โ 30 000 | Guards HTTP-date math against small client/server skew |
epochSniffThreshold |
number | 1e9 |
โ | Above this, treat a *-Reset value as absolute epoch seconds |
Parser walkthrough
The parsing layer is a pure function: headers in, milliseconds out. Keeping it side-effect-free makes it trivially testable and reusable across fetch, Axios interceptors, and worker threads.
// retry-after.ts โ pure header-to-delay parser with sanitization.
export interface RetryOpts {
maxDelayMs?: number; // hard ceiling for hostile values
defaultDelayMs?: number; // used when no timing header exists
minDelayMs?: number; // floor to avoid hot-loop retries
jitterRatio?: number; // 0..1 fraction randomized
epochSniffThreshold?: number;
}
// Returns milliseconds to wait before the next attempt โ always finite & bounded.
export function retryDelayMs(headers: Headers, opts: RetryOpts = {}): number {
const max = opts.maxDelayMs ?? 300_000;
const def = opts.defaultDelayMs ?? 1_000;
const min = opts.minDelayMs ?? 0;
const jit = opts.jitterRatio ?? 0.2;
const epochCut = opts.epochSniffThreshold ?? 1e9;
let ms = parseRetryAfter(headers.get("retry-after"));
if (ms === null) ms = parseReset(headers.get("ratelimit-reset"), epochCut);
if (ms === null) ms = parseReset(headers.get("x-ratelimit-reset"), epochCut);
if (ms === null) ms = def; // header absent โ fall back to a safe default
// Clamp BEFORE jitter so the ceiling is honored, then de-correlate clients.
ms = Math.min(Math.max(ms, min), max);
const jitter = ms * jit * Math.random();
return Math.round(ms + jitter);
}
function parseRetryAfter(raw: string | null): number | null {
if (raw == null) return null;
const v = raw.trim();
if (/^\d+$/.test(v)) return Number(v) * 1000; // delta-seconds form
const when = Date.parse(v); // HTTP-date form
if (Number.isNaN(when)) return null; // unparseable โ fall through
return Math.max(0, when - Date.now()); // never negative
}
function parseReset(raw: string | null, epochCut: number): number | null {
if (raw == null || !/^\d+$/.test(raw.trim())) return null;
const n = Number(raw.trim());
// Magnitude sniff: large value is an absolute epoch, small is seconds-remaining.
return n > epochCut ? Math.max(0, n * 1000 - Date.now()) : n * 1000;
}
The two design choices that matter most: clamp before jitter (so a malicious Retry-After: 99999999 can never escape maxDelayMs), and treat any unparseable value as absent rather than throwing โ a broken header should degrade to the default, never crash the retry path.
Failure modes & mitigations
- Oversized values. An upstream bug or hostile proxy sends
Retry-After: 2147483647. WithoutmaxDelayMsthe client effectively hangs. The clamp is non-negotiable. - Negative HTTP-date. A past timestamp yields a negative delay;
Math.max(0, โฆ)collapses it to an immediate (jittered) retry. - Clock skew on HTTP-date. Prefer servers emit delta-seconds. When you must consume HTTP-date, the small
clockSkewToleranceMsfloor prevents a slightly-fast client from retrying a hair too early. - Header absent. Around half of
429s in the wild carry noRetry-After. TheRateLimit-Resetfallback, thendefaultDelayMs, keeps the client well-behaved. - Thundering herd. A shared absolute reset time releases every client at once. Jitter (and, for repeated failures, exponential backoff) spreads the retry storm.
Child topics
- Parsing Retry-After: HTTP-date vs Seconds โ a battle-tested parser for both formats, the clock-skew risk, max-cap, and returning ms-until-retry, with FAQs.
Related
- Frontend Resilience & UX Handling โ the parent area covering client-side rate-limit behavior.
- Handling 429 HTTP Responses โ what to do once you have a delay value.
- Exponential Backoff & UX โ layering computed backoff on top of the parsed header.
- Client-Side Rate-Limit State โ using
RateLimit-Remainingto throttle before you ever see a 429.