# 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`.