Files
suaveplan/openspec/specs/retry/spec.md
2026-05-27 23:29:53 +01:00

78 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.