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.

Retry-After parsing pipeline A decision pipeline that reads Retry-After, branches on numeric versus date form, falls back to RateLimit-Reset or a default, clamps the value, and adds jitter. 429 response read headers Retry-After present? all-digits โ†’ secs else โ†’ HTTP-date fallback: RateLimit-Reset clamp 0 โ‰ค ms โ‰ค max + jitter schedule retry absent

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. Without maxDelayMs the 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 clockSkewToleranceMs floor prevents a slightly-fast client from retrying a hair too early.
  • Header absent. Around half of 429s in the wild carry no Retry-After. The RateLimit-Reset fallback, then defaultDelayMs, 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