Repository navigation
proposal(http): add isRetriableFetchError and a Retry-After parser #7296
Description
Is your feature request related to a problem? Please describe.
retry() grew an isRetriable hook for exactly this job (#6196, stabilized in #6944), but std ships nothing to put in it. The retry JSDoc even shows users how to hand-roll the predicate: define your own HttpError, check status === 429 || status >= 500 (the "Only retry on specific error types" example in async/retry.ts).
In the wild that copy-paste goes worse. A work codebase I maintain rolls this predicate in three places, each one matching substrings in error.message ("429", "throttl", "timeout"). That false-positives the moment a payload containing "429" ends up in an error message. Everyone writing a fetch retry loop rebuilds this, and most versions are wrong in a different way.
Describe the solution you'd like
Two small unstable APIs in @std/http.
Implementation:
- feat(http/unstable): add
isRetriableFetchError#7298 addsisRetriableFetchError - feat(http/unstable): add
parseRetryAfter#7299 addsparseRetryAfter
1. A predicate for retry's isRetriable hook
// http/unstable_is_retriable_fetch_error.ts
export function isRetriableFetchError(error: unknown): boolean;Walks the value and its cause chain (depth-bounded, cycle-safe) and returns true for:
- a thrown
Responsewith status 408, 429, or any 5xx, including nonstandard codes such as Cloudflare's 522 - an
Errorwith an integerstatusproperty in 100-599, using the same status test. This covers the class inretry's own docs and other errors with a directstatusproperty - a
DOMExceptionnamedTimeoutError, i.e. whatAbortSignal.timeout()throws - a
TypeErrormatching a known failed-fetch message (fetch failedon modern Deno, Node.js, and Bun, plus browser messages), or theerror sending requestprefix from older Deno releases. Never arbitrary substring search, for the reason above
Everything else is false, including AbortError: abort is caller intent, and retrying a cancelled request is a bug. False negatives are the safe direction, since the caller sees the original error immediately.
Usage:
import { retry } from "@std/async/retry";
import { isRetriableFetchError } from "@std/http/unstable-is-retriable-fetch-error";
const data = await retry(async () => {
const res = await fetch(url);
if (!res.ok) throw res;
return await res.json();
}, { isRetriable: isRetriableFetchError });2. A Retry-After parser
// http/unstable_retry_after.ts
export interface ParseRetryAfterOptions {
now?: Date;
}
export function parseRetryAfter(
value: string | null,
options?: ParseRetryAfterOptions,
): number | null;Handles both RFC 9110 §10.2.3 forms (delta-seconds and HTTP-date), returns milliseconds to wait, null when missing or unparseable. It takes the raw value from headers.get("retry-after"), with no dependency on the HEADER catalog. #7239 removes that catalog. Useful on its own today, and a prerequisite for retry() ever honoring the header. Honoring it needs a per-attempt delay hook on retry, which would be a separate proposal(async) if this lands. Nothing here touches stable retry behavior.
Why @std/http and not @std/async
The substance is all HTTP: status classification, header parsing, fetch error shapes. Since isRetriable is structurally (err: unknown) => boolean, nothing here imports @std/async. No circular dependency, and @std/async stays free of domain knowledge.
Describe alternatives you've considered
- Status quo: keep pointing users at the docs example. That example is how the substring heuristics happen.
instanceof HttpErrorfor the status check.HttpError(feat(http/unstable):HttpError#7132) is still unstable. Direct integration can follow once it stabilizes. The structural check covers errors with a directstatusproperty today.
Activity