Initial Commit
This commit is contained in:
77
openspec/specs/retry/spec.md
Normal file
77
openspec/specs/retry/spec.md
Normal 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`.
|
||||
Reference in New Issue
Block a user