4.0 KiB
retry-helper
(no submodules)
Purpose
Coordinates retry logic for HTTP requests issued by HttpStorage. Distinguishes transient outcomes (HTTP 408/425/429/5xx or fetch-level network rejection) from fatal ones, honours Retry-After when the server provides it, and falls back to exponential backoff with a configurable cap. AbortSignal is plumbed through every wait so cancellation lands within milliseconds, never blocked on a timer.
The helper exists because two of the spec's requirements (Retry-After honour and the precise classifier) are too provider-specific to delegate to @suaveplan/retry directly — the latter is generic across error types, while this module knows about HTTP semantics.
Features
isTransientStatus(status)—408,425,429, or any5xx.isTransientError(err)—StorageHttpError(NETWORK_ERROR)produced by thefetchadapter.parseRetryAfter(value, now)— supports delta-seconds and HTTP-date forms; caps atRETRY_AFTER_MAX_DELAY_MS(60s) to defend against malicious origins.retryWithBackoff(fn, options)— runsfnup tomaxAttempts, classifies outcomes, sleeps between attempts, supports cancellation.- Injectable
TimersLikeso tests pin behaviour under aFakeClockwithout real timers.
Usage: Basic
import { retryWithBackoff } from "@suaveplan/storage-http";
const response = await retryWithBackoff(() => fetch("https://cdn.example.com/file.bin"), {
retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 5_000 },
});
Usage: Advanced
import { retryWithBackoff, isTransientStatus, parseRetryAfter } from "@suaveplan/storage-http";
import { fakeClock } from "@suaveplan/testing/runner";
const clock = fakeClock();
const response = await retryWithBackoff(
async () => fetch("https://cdn.example.com/file.bin", { signal }),
{
retry: { maxAttempts: 3, honourRetryAfter: true },
timers: clock.timers,
signal,
},
);
API Reference
retryWithBackoff(fn, options?): Promise<Response>
fn: () => Promise<Response>— attempts the request.options.retry?—maxAttempts,baseDelayMs,maxDelayMs,honourRetryAfter(defaulttrue).options.timers?— defaults toglobalThis.setTimeout/clearTimeout.options.signal?— cancels the wait between attempts; pre-aborted signal short-circuits withSTORAGE_HTTP.ABORTED.
Returns the last response after exhaustion (caller decides how to surface a transient final status) or throws the captured error.
isTransientStatus(status: number): boolean
True for 408, 425, 429, or any value in [500, 599].
isTransientError(err: unknown): boolean
True for StorageHttpError with code STORAGE_HTTP.NETWORK_ERROR.
parseRetryAfter(value: string | null, now: number): number | undefined
- Numeric value → delta-seconds × 1000 (capped at
RETRY_AFTER_MAX_DELAY_MS). - HTTP-date →
parseDate − now(clamped to 0 if in the past, capped on the high side). - Missing / empty / unparseable →
undefined.
Implementation Notes
5xxis intentionally handled as a range check, not as set membership, so the constants module doesn't need 100 entries.- The
Retry-Aftercap is distinct frommaxDelayMs; the latter governs exponential backoff, the former specifically defends against the "1 hour" abuse pattern. sleeplistens to the abort event with{ once: true }and tears down both the abort listener and the underlying timer on either resolution path — no listener leaks.- The helper is
fetch-shaped (returns aResponse) rather than generic; this is deliberate. HTTP semantics drive every retry decision, and a generic helper would either obscure the classifier or duplicate it. - Exhaustion returns the last response (when one was received) rather than throwing —
HttpStorage's wrapper is the right place to map a final5xxtoSTORAGE_HTTP.UPSTREAM_ERRORbecause it has the URL and key context.