Parsing Retry-After: HTTP-date vs Seconds

The exact task here is turning a single Retry-After header β€” which may arrive as 120 or as Fri, 20 Jun 2026 18:30:00 GMT β€” into a finite, bounded number of milliseconds you can hand to setTimeout. It sits under the Retry-After parsing guide, and it is the one piece of the retry stack where a careless line of code turns a transient 429 into a multi-hour client stall. Get the two formats and the cap right once, and every retry path above it inherits correct behavior.

The problem in concrete numbers

Picture a dashboard that polls a search API limited to 600 requests/minute. Under burst load the API returns 429 with Retry-After: 30. Multiply that across 4 000 concurrent browser tabs and, without jitter, all 4 000 retry at the same instant 30 s later β€” a self-inflicted spike that re-trips the limit. Worse, one regional edge node is misconfigured and emits Retry-After: 31536000 (one year in seconds). A naive setTimeout(retry, seconds * 1000) schedules a retry 365 days out; the tab silently never recovers. A correct parser caps that to a sane maxDelayMs (say 5 minutes) and jitters the 30 s case across a window so the herd disperses.

delta-seconds vs HTTP-date

Aspect delta-seconds (120) HTTP-date (Fri, 20 Jun 2026 18:30:00 GMT)
Form Non-negative integer IMF-fixdate string
Client computation value * 1000 Date.parse(value) βˆ’ Date.now()
Clock-skew exposure None β€” relative High β€” depends on client clock accuracy
Negative result possible No Yes (past date) β†’ floor to 0
Parse failure mode /^\d+$/ miss β†’ treat absent Date.parse β†’ NaN β†’ treat absent
Server recommendation Preferred Use only if you need an absolute instant
Typical source Most APIs, Cloudflare, nginx Some CDNs, Expires-style backends

The takeaway: delta-seconds is strictly safer to consume because it carries no dependency on the client’s wall clock. When you operate the server, emit delta-seconds. When you consume a third-party API you must handle both.

Branching a Retry-After value into milliseconds A flow that tests whether Retry-After is all digits, parses it as seconds or as an HTTP-date, floors negatives, caps the maximum, and outputs milliseconds. Retry-After raw string all digits? /^\d+$/ seconds Γ— 1000 no skew Date.parse βˆ’ now clock-skew risk max(0, x), cap β†’ ms yes no

Step-by-step implementation

  • Trim the raw header and bail to null
  • Test /^\d+$/
  • Otherwise Date.parse it; on NaN, return null
  • For the HTTP-date branch, subtract Date.now()
  • Apply a maxCapMs
  • Return milliseconds (or null
// parse-retry-after.ts β€” robust, both-format parser returning ms-until-retry.
export interface ParseOpts {
  maxCapMs?: number;          // ceiling for any computed delay (default 5 min)
  skewToleranceMs?: number;   // absorbs small client/server clock skew
  now?: () => number;         // injectable clock for deterministic tests
}

/**
 * Parse a Retry-After header value into milliseconds to wait.
 * Returns null when the value is absent or unparseable so the caller can
 * fall back to RateLimit-Reset or a default β€” this function never throws.
 */
export function parseRetryAfter(
  raw: string | null | undefined,
  opts: ParseOpts = {},
): number | null {
  const maxCap = opts.maxCapMs ?? 300_000;
  const skew = opts.skewToleranceMs ?? 2_000;
  const now = opts.now ?? Date.now;

  if (raw == null) return null;
  const v = raw.trim();
  if (v === "") return null;

  let ms: number;
  if (/^\d+$/.test(v)) {
    // delta-seconds: relative, no clock dependency. Safe path.
    ms = Number(v) * 1000;
  } else {
    // HTTP-date: absolute. Date.parse handles IMF-fixdate; NaN means malformed.
    const when = Date.parse(v);
    if (Number.isNaN(when)) return null;
    // Subtract our clock; floor at 0 so a past date retries immediately.
    // skew tolerance keeps a slightly-fast client from retrying too early.
    ms = Math.max(0, when - now() + skew);
  }

  if (!Number.isFinite(ms) || ms < 0) return null; // paranoia against odd inputs
  return Math.min(ms, maxCap);                      // hard cap defeats oversized values
}

Wire it into a fetch retry like this β€” parseRetryAfter decides how long, your backoff decides whether to keep going:

async function fetchWithRetry(url: string, max = 3): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url);
    if (res.status !== 429 || attempt >= max) return res;
    const headerMs = parseRetryAfter(res.headers.get("retry-after"));
    const waitMs = headerMs ?? 1000 * 2 ** attempt;     // fall back to exp backoff
    const jittered = waitMs * (0.5 + Math.random() * 0.5); // de-correlate clients
    await new Promise((r) => setTimeout(r, jittered));
  }
}

Gotchas & edge cases

  • Retry-After: 0 is legal and means β€œretry immediately.” Don’t treat 0 as falsy/absent β€” /^\d+$/ matches it and yields 0, which is correct.
  • Comma-separated lists. A misbehaving proxy may join duplicate headers into Retry-After: 30, 30. The digit test fails and the date parse fails, so it degrades to the fallback β€” acceptable, but log it.
  • Locale-aware Date.parse. Only the IMF-fixdate (GMT) form is guaranteed; obsolete RFC 850 / asctime forms parse inconsistently across engines. Prefer servers emit delta-seconds for exactly this reason.
  • 32-bit overflow downstream. setTimeout clamps delays above ~24.8 days to 1 ms in some engines. Your maxCapMs keeps you far below that, but never feed an unclamped value to a timer.
  • Skew tolerance cuts both ways. A large skewToleranceMs makes you wait longer than told; keep it to a couple of seconds.

Verification & testing

Drive the parser with a table of adversarial inputs and assert the bounds hold:

import { parseRetryAfter } from "./parse-retry-after";
const fixedNow = () => Date.parse("Fri, 20 Jun 2026 18:00:00 GMT");

console.assert(parseRetryAfter("120", { now: fixedNow }) === 120_000, "seconds");
console.assert(parseRetryAfter("0", { now: fixedNow }) === 0, "zero is immediate");
console.assert(parseRetryAfter(null) === null, "absent");
console.assert(parseRetryAfter("garbage") === null, "unparseable");
console.assert(
  parseRetryAfter("31536000", { now: fixedNow }) === 300_000, "oversized capped");
console.assert(
  parseRetryAfter("Fri, 20 Jun 2026 18:30:00 GMT", { now: fixedNow, skewToleranceMs: 0 })
    === 1_800_000 - 0 + 0 && true, "http-date ~30min before cap"); // capped to 300_000

Then confirm end-to-end against a live limit with curl, reading the header you actually receive:

# Hammer until limited, then inspect the Retry-After form the server emits.
curl -s -D - -o /dev/null https://api.example.com/v1/search \
  | grep -i -E 'retry-after|ratelimit-reset'

Frequently Asked Questions

Should I prefer Retry-After or compute my own backoff?

Honor Retry-After when present β€” the server knows when its window resets better than you do. Use computed exponential backoff only as the fallback when the header is absent or unparseable, and cap both with the same maxCapMs.

Why is the HTTP-date form risky?

Turning an absolute timestamp into a delay requires subtracting the client's own clock, which may be wrong. A client running fast retries early and re-trips the limit; one running slow waits too long. delta-seconds carries no such dependency, so prefer it on servers you control.

What cap should I use for maxCapMs?

Pick the longest a user would tolerate waiting before you'd rather show an error and let them retry manually β€” commonly 60 s to 5 min for interactive UIs. The exact number matters less than having one; an uncapped parser can stall for years on a single bad header.

Does Date.parse handle every Retry-After date format?

Reliably only the IMF-fixdate (RFC 9110 preferred) form. The obsolete RFC 850 and asctime forms parse inconsistently across JavaScript engines. Since they're rare in modern APIs, returning null on a parse failure and falling back is the pragmatic choice.