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.
Where the Value Comes From and What It Means
Retry-After is one of the few headers a client is expected to obey rather than merely record, so it is worth being precise about what it communicates.
It is a statement about capacity, not about the request. The server is saying “there will be room at approximately this time”, which implies nothing about whether the request was valid, whether it was partially processed, or whether repeating it is safe. That last question belongs to idempotency, not to the header, and clients that treat a wait as permission to replay a write are conflating two different guarantees.
It is also approximate by construction. A limiter computes the wait from its own state at the moment of rejection, and by the time the client acts on it other traffic has consumed capacity. Clients should therefore treat the value as a floor rather than a promise, add jitter, and expect that retrying exactly at the advertised instant will sometimes be rejected again — which is normal, not a bug.
Finally, it is not the only way a server expresses a wait. Some APIs put the value in a body field, some in a vendor-specific header, and some publish a reset instant instead of a duration. A parser that handles the standard header first and falls back to a documented alternative covers real-world APIs far better than one that assumes a single source.
Reconciling the Header with a Reset Value
Many responses carry both a wait and a reset instant, and the two answer different questions: the wait says when the client may try again, while the reset says when the current window’s allowance is restored in full. Confusing them produces two symptoms — clients that idle far longer than necessary, and clients that hammer the moment the window rolls.
The rule that resolves it: obey the wait, display the reset. The wait is the number the client’s scheduler should use, because it accounts for partial recovery — a bucket that refills continuously can admit a request long before the window boundary. The reset is the number the interface should show when telling a user when their full allowance returns.
When the two disagree by a large margin, the wait is usually right and the reset is usually a window boundary being reported as though it were a recovery time. That mismatch is worth logging on the client, because it is a reliable indicator that the server is computing its wait from the wrong source — the exact bug that makes clients wait a minute for capacity that returned in a second.
Practical Parsing Rules
Four rules make a parser robust against real servers.
Accept both forms — a bare number of seconds and a date — because both are legal and both appear in the wild. Clamp the result to a plausible range so that a malformed value or a skewed clock cannot produce an instant retry or an hour-long pause. Treat absent, zero, and unparseable identically, falling back to a minimum wait with jitter rather than to zero. And return a duration rather than an instant from the parser itself, leaving the caller to convert it against whatever clock it trusts — that separation keeps the parser pure and makes it trivially testable.
Server-Side Obligations
Because clients are asked to obey this header, servers carry matching obligations, and most client-side pain traces back to one of them being unmet.
Emit it on every rejection. A rate-limit response without a wait forces every client to guess, and their guesses will be wrong in both directions. It costs one header.
Never emit zero. A zero tells a well-behaved client to retry immediately, which produces the tight loop the limit exists to prevent. Clamp to at least one second.
Derive it from the limiter, not from a clock. The most common server-side bug is reporting the time until a window boundary when the limiter would actually admit a request much sooner. Clients then idle for tens of seconds of capacity they were entitled to use.
Keep it consistent with any reset value. If the same response carries both, they should be explicable together: the wait is when a single request may proceed, the reset is when the full allowance returns. Values that contradict each other train clients to ignore both.
Use one encoding and document it. Switching between seconds and dates within an API version breaks clients that implemented only one branch, and there is no upside to the variety.
A server that meets those five obligations makes correct client behaviour easy; a server that does not guarantees that every integration reinvents the same defensive parsing, badly and differently.
Putting the Parser Somewhere Sensible
The parsing logic is five lines; the discipline is keeping it in one place.
Export it from the same transport module that owns rejection handling, and have every caller — the retry layer, the queue, the interface countdown — use that single function. Applications that reimplement it per feature end up with several notions of how long to wait, which surfaces as components that resume at different times and an interface that contradicts itself.
Give it a narrow signature: a header value in, a duration in milliseconds out, with zero meaning “no usable value”. Converting to an instant belongs to the caller, because only the caller knows which clock it trusts and whether it needs to persist the result. Keeping the conversion out of the parser also makes it trivially testable — a table of inputs and expected durations, no clock mocking required.
Add the table-driven test with every case you have seen in the wild: a plain number, a date, a zero, an empty string, a negative number, a date in the past, and something malformed. Every one of those has appeared in a real response somewhere, and the test costs less than the first support ticket caused by mishandling one.
Reference Behaviour to Implement
The complete behaviour a client needs is short enough to state as rules.
Parse the header if present, accepting both a duration in seconds and a date. Convert a date by differencing against the current time. Clamp the result to a plausible range — a floor of one second, a ceiling of a few minutes for interactive work. If the header is absent, unparseable, or resolves to zero, fall back to the client’s own schedule with a floor of one second. Add jitter in every case, because the value the server sent was sent to many clients at once. Record the parsed value in telemetry so a server emitting nonsense becomes visible. And treat the result as a floor rather than a promise: retrying exactly when told will occasionally be rejected again, and that is expected rather than an error.
Those seven rules cover every real-world case this page describes, and they fit in a function small enough to read in one screen. The value of writing them down is that each one corresponds to a bug that has shipped in production somewhere — usually more than once.
Interoperability Notes
Real APIs vary more than the specification suggests, and a few observed behaviours are worth handling defensively.
Some services send a wait on every response, not only on rejections, using it to advertise a recommended pacing interval. Treating that as a mandatory pause would idle the client unnecessarily, so only apply the value when the response actually indicates a rejection.
Some send fractional seconds. Parsing as an integer silently truncates 0.4 to zero, which then falls into the “no usable value” branch and produces a longer wait than intended. Parse as a floating-point number and round up.
Some send the header on redirects and on server errors, where it means “the resource will be available then” rather than “you are limited”. The distinction matters for whether the client should pause other requests to the same scope — a rate limit should, a slow resource should not.
Some publish a reset instant instead, with no wait at all. Deriving a duration from it is straightforward, but remember the semantic difference: a reset is when the full allowance returns, so using it as the wait means idling through capacity that returned earlier.
Handling those four cases costs a few extra lines and removes the class of integration bug that only appears against one particular provider, months after the client shipped.
The deep dive below works through both wire formats in detail, including the clamping rules that keep a skewed client clock from turning a two-second wait into an hour of idling.
Treat the parser as a small piece of shared infrastructure rather than as feature code, and every consumer of it inherits correct behaviour for free.
Parsed correctly and obeyed faithfully, this one header removes most of the guesswork from client-side recovery — and ignoring it is the fastest way to turn a temporary limit into a longer one.
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.