first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# retry-helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.