# Capability Spec: Retry **Package**: `@suaveplan/retry` **Version**: 0.0.0 (genesis) **Status**: ADDED --- ## ADDED 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/`.