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.
Step-by-step implementation
- Trim the raw header and bail to
null - Test
/^\d+$/ - Otherwise
Date.parseit; onNaN, returnnull - 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: 0is legal and means βretry immediately.β Donβt treat 0 as falsy/absent β/^\d+$/matches it and yields0, 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.
setTimeoutclamps delays above ~24.8 days to 1 ms in some engines. YourmaxCapMskeeps you far below that, but never feed an unclamped value to a timer. - Skew tolerance cuts both ways. A large
skewToleranceMsmakes 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.
Related
- Retry-After Parsing β the parent guide covering RateLimit-Reset fallback and clamping.
- Exponential Backoff & UX β what to do when the header is absent.
- Exponential Backoff With Jitter in the Browser β combining the parsed delay with computed backoff.
- Distributed Algorithm Sync β the clock-skew problem behind HTTP-date parsing.