Files
skills/docs/persistence/storage/storage-http/src/retry-helper/retry-helper.md
2026-05-29 08:20:56 +01:00

4.0 KiB
Raw Blame History

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 any 5xx.
  • isTransientError(err) — StorageHttpError(NETWORK_ERROR) produced by the fetch adapter.
  • parseRetryAfter(value, now) — supports delta-seconds and HTTP-date forms; caps at RETRY_AFTER_MAX_DELAY_MS (60s) to defend against malicious origins.
  • retryWithBackoff(fn, options) — runs fn up to maxAttempts, classifies outcomes, sleeps between attempts, supports cancellation.
  • Injectable TimersLike so tests pin behaviour under a FakeClock without 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 (default true).
  • options.timers? — defaults to globalThis.setTimeout/clearTimeout.
  • options.signal? — cancels the wait between attempts; pre-aborted signal short-circuits with STORAGE_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

  • 5xx is intentionally handled as a range check, not as set membership, so the constants module doesn't need 100 entries.
  • The Retry-After cap is distinct from maxDelayMs; the latter governs exponential backoff, the former specifically defends against the "1 hour" abuse pattern.
  • sleep listens 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 a Response) 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 final 5xx to STORAGE_HTTP.UPSTREAM_ERROR because it has the URL and key context.