5.6 KiB
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)— fixeddelayms regardless of attempt number.createLinearBackoff(opts)—initial + (attempt - 1) * incrementms.createExponentialBackoff(opts)—initial * base^(attempt-1)ms, capped atmax.applyJitter(delay, mode, rng?)— modifies delay bymode:'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 backoffinitial: 1000, a FakeClock-backedtimers, 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
RetryExhaustedErrorwithattempts === 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
timersand anAbortSignal - WHEN the signal is aborted while a
setTimer-scheduled wait is pending - THEN the pending timer is cleared via
timers.clearTimerand the loop rejects withRetryAbortedError.