58 lines
3.5 KiB
Markdown
58 lines
3.5 KiB
Markdown
# 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/`.
|