Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
# retry
## Purpose
Retry strategies and circuit breaker for the Suaveplan monorepo — constant/linear/exponential backoff with full/equal/decorrelated/none jitter, sleep and timeout helpers, and a `closed → open → half-open` circuit breaker state machine with injectable clock and RNG. AbortSignal threads through every entry point, structured `RetryError` codes are registered under `RETRY.*`, and state transitions emit `stateChange` events via `@suaveplan/events`.
## Requirements
### RT-01 — Core retry contract
`retry(fn, config)` MUST call `fn` at most `config.maxAttempts` times. Between attempts it MUST sleep for `delayFn(attempt)` milliseconds. If `config.shouldRetry` is provided, it MUST be consulted before each retry; returning `false` stops retrying (result is `RetryExhaustedError`). On success it MUST return `{ value, attempts, totalMs }`.
### RT-02 — Abort between attempts
`retry` MUST check `config.signal` before each sleep AND before each fn call. A cancelled `AbortSignal` MUST cause `retry` to reject with `RetryError` (code `RETRY.ABORTED`) — not `RetryExhaustedError`. The signal is also forwarded to `fn` as its argument.
### RT-03 — Injectable clock and RNG
`RetryConfig.clock?: Clock` overrides the sleep timer for deterministic tests. `RetryConfig.rng?: Rng` is forwarded to jitter computations so jitter is reproducible under test.
### RT-04 — Backoff strategies
- `createConstantBackoff(opts)` — fixed `delay` ms regardless of attempt number.
- `createLinearBackoff(opts)` — `initial + (attempt - 1) * increment` ms.
- `createExponentialBackoff(opts)` — `initial * base^(attempt-1)` ms, capped at `max`.
- `applyJitter(delay, mode, rng?)` — modifies delay by `mode`: `'full'` (uniform 0–delay), `'equal'` (delay/2 + uniform 0–delay/2), `'decorrelated'` (decorrelated random walk), `'none'` (identity).
- `clampDelay(delay, min, max)` — clamps to `[min, max]`.
### RT-05 — Timeout
`withTimeout(fn, ms, signal?)` MUST reject with `AttemptTimeoutError` (code `RETRY.TIMEOUT`) if `fn` does not settle within `ms` milliseconds. The internal timeout is cleaned up on both success and failure. Accepts an optional `signal` for external cancellation.
### RT-06 — Circuit breaker state machine
`CircuitBreaker` MUST implement: `closed → open` on reaching `failureThreshold` consecutive failures; `open → half-open` after `timeout` ms (using injected `clock`); `half-open → closed` after `successThreshold` consecutive successes in half-open probes; `half-open → open` on any failure. `.call(fn)` in `'open'` state MUST throw `CircuitOpenError` without calling `fn`. State transitions MUST emit `stateChange` events via `@suaveplan/events`.
### RT-07 — Exhaustion error shape
`RetryExhaustedError` MUST carry `.attempts: number` and `.lastError: unknown`. `AttemptTimeoutError` MUST carry `.attemptNumber: number` and `.timeoutMs: number`. Both extend `RetryError` which extends `SuaveplanError`.
### RT-08 — Structured errors
All thrown errors MUST extend `RetryError` with codes under `RETRY.*` namespace. `CircuitOpenError` extends `RetryError` with code `RETRY.CIRCUIT_OPEN`.
### RT-09 — Zod schemas
`RetryConfig`, all backoff option types, and `CircuitBreakerConfig` MUST have Zod schemas exported from `*.schemas.ts` files. Options are validated at the boundary; invalid options throw `RetryError` with context.
### RT-10 — OTel instrumentation
`retry.attempt.total` counter (by outcome: success/failure/exhausted/aborted), `retry.attempt.delay_ms` histogram (by strategy), `circuit_breaker.state_change.total` counter (by from/to), `circuit_breaker.call.duration_ms` histogram MUST be emitted via `@opentelemetry/api`. All are no-ops without exporter.
### RT-11 — sideEffects: false + coverage 100%
`"sideEffects": false` with tree-shake test. Vitest thresholds at 100/100/100/100. No `.skip` / `.only` in `src/`.
### RT-12 — Pluggable timer source
`@suaveplan/retry` MUST accept an optional `TimersLike` (from `@suaveplan/types`) on `RetryConfig.timers`. When supplied, every internal scheduling primitive — `sleep`, `withTimeout`, and the inter-attempt delay inside `retry()` — MUST drive its wait through `timers.setTimer` / `timers.clearTimer` rather than `globalThis.setTimeout` / `clearTimeout`. When omitted, the implementation MUST fall back to platform timers, preserving the historical default. The exported `sleep(ms, signal?, timers?)` and `withTimeout(fn, ms, attemptNumber, signal?, timers?)` helpers accept the same optional `TimersLike` as a trailing parameter; existing call sites omitting it continue to compile and behave identically.
#### Scenario: FakeClock drives backoff deterministically
- **GIVEN** a retry loop with `maxAttempts: 5`, exponential backoff `initial: 1000`, a FakeClock-backed `timers`, and a handler that always rejects
- **WHEN** the loop is invoked and the FakeClock is advanced by the cumulative scheduled delay
- **THEN** all 5 attempts execute in zero real wall-clock time and the final rejection is `RetryExhaustedError` with `attempts === 5`.
#### Scenario: Platform timers still work without `timers`
- **GIVEN** a retry loop configured without `timers`
- **WHEN** the loop executes against a transient failure that recovers on attempt 2
- **THEN** the second attempt runs after the configured delay measured against real wall-clock time.
#### Scenario: Abort during timer-driven sleep
- **GIVEN** a retry loop with an injected `timers` and an `AbortSignal`
- **WHEN** the signal is aborted while a `setTimer`-scheduled wait is pending
- **THEN** the pending timer is cleared via `timers.clearTimer` and the loop rejects with `RetryAbortedError`.