# OpenSpec Change: enterprise-retry - **Change ID**: enterprise-retry - **Package**: `@suaveplan/retry` - **Category**: universal - **Target path**: `packages/universal/retry` - **Status**: IN PROGRESS - **Date**: 2026-04-18 - **Reference spec**: `/mnt/s/web/suaveplan/enterprise/retry.md` ## Summary Greenfield implementation of `@suaveplan/retry` for the genesis branch. The existing main-branch retry package (100% test coverage) provides the baseline public-API surface to preserve; this change delivers enterprise-grade retry-with-backoff strategies (constant, linear, exponential + jitter), circuit breaker with half-open probing, sleep/timeout helpers, injectable clock and RNG, `AbortSignal` support throughout, structured errors, Zod schemas, property-based tests, OTel instrumentation, and benchmarks. ## Current-state audit (genesis) The package does not exist on genesis yet — this is a fresh build. ## Available workspace deps on genesis | Package | Available | |---------|-----------| | `@suaveplan/error` | ✅ | | `@suaveplan/types` | ✅ | | `@suaveplan/events` | ✅ | | `@suaveplan/opentelemetry` | ❌ not yet built — use `@opentelemetry/api` directly | | `@suaveplan/feature-flags` | ❌ not yet built — skip | | `@suaveplan/schema-registry` | ❌ not yet built — export Zod schemas directly | External deps (via catalog): - `zod` → `catalog:forms` - `@opentelemetry/api` → `catalog:observability` - `tslib` → `catalog:build` Dev deps (via catalog): - `vitest`, `@vitest/coverage-istanbul` → `catalog:testing` - `fast-check` → `catalog:testing` - `mitata` → `catalog:testing` ## Preserved public API surface (from main branch) ### Core retry - `retry(fn, config)` → `Promise>` — core retry primitive - `withRetry(fn, config)` → `Promise` — throws on exhaustion - `withCircuitBreaker(fn, breaker)` → `Promise` — wraps fn with circuit breaker - `withTimeout(fn, ms, signal?)` → `Promise` — wraps fn with deadline - `sleep(ms, signal?)` → `Promise` — cancellable sleep - `createDelayFn(strategy)` → `DelayFunction` — adapts a strategy to a delay function ### Backoff strategies - `createConstantBackoff(opts)` → `RetryStrategy` — fixed delay - `createLinearBackoff(opts)` → `RetryStrategy` — linearly increasing delay - `createExponentialBackoff(opts)` → `RetryStrategy` — exponentially increasing delay - `applyJitter(delay, mode, rng?)` → `number` — full / equal / decorrelated jitter - `clampDelay(delay, min, max)` → `number` ### Circuit breaker - `CircuitBreaker` class — `.call(fn)`, `.reset()`, `.state` getter, event emitters - `CircuitOpenError` extends `SuaveplanError` - `CircuitBreakerConfig` — `{ failureThreshold, successThreshold, timeout, halfOpenProbes }` - `CircuitBreakerEventMap` — `{ stateChange: CircuitState }` - `CircuitState` — `'closed' | 'open' | 'half-open'` ### Types - `RetryConfig` — `{ maxAttempts, delay, shouldRetry?, signal?, clock?, rng? }` - `RetryResult` — `{ value: T; attempts: number; totalMs: number }` - `RetryStrategy` — `(attempt: number) => number` (delay in ms) - `DelayFunction` — alias for `RetryStrategy` - `JitterMode` — `'full' | 'equal' | 'decorrelated' | 'none'` - `ConstantBackoffOptions`, `LinearBackoffOptions`, `ExponentialBackoffOptions` ### Error types - `RetryExhaustedError` extends `SuaveplanError` — `.attempts`, `.lastError` - `AttemptTimeoutError` extends `SuaveplanError` — `.attemptNumber`, `.timeoutMs` ## New additions for genesis ### Structured errors - `RetryError` base extends `SuaveplanError` with codes under `RETRY.*` namespace - Codes: `RETRY.EXHAUSTED`, `RETRY.ABORTED`, `RETRY.TIMEOUT`, `RETRY.CIRCUIT_OPEN` - `RetryExhaustedError`, `AttemptTimeoutError`, `CircuitOpenError` extend `RetryError` ### Zod schemas - `RetryConfigSchema`, `ConstantBackoffOptionsSchema`, `LinearBackoffOptionsSchema`, `ExponentialBackoffOptionsSchema`, `CircuitBreakerConfigSchema` ### Injectable clock + RNG - `RetryConfig.clock?: Clock` — for deterministic sleep (FakeClock in tests) - `RetryConfig.rng?: Rng` — for deterministic jitter computation - `CircuitBreakerConfig.clock?: Clock` ### AbortSignal throughout - `sleep(ms, signal?)` — resolves early on abort - `retry(fn, { signal })` — cancels between attempts on abort - `CircuitBreaker.call(fn, signal?)` — passes signal through to fn ### OTel instrumentation - Counter: `retry.attempt.total` (by outcome label: success / failure / exhausted / aborted) - Histogram: `retry.attempt.delay_ms` (per strategy label) - Counter: `circuit_breaker.state_change.total` (by from/to label) - Histogram: `circuit_breaker.call.duration_ms` ### Property-based tests (fast-check) - Exponential backoff: `delay(n+1) >= delay(n)` for all n (monotone with no jitter) - Jitter bounds: `applyJitter(d, 'full', rng)` always in `[0, d]` - Clamp: `clampDelay(d, min, max)` always in `[min, max]` - Retry count: `withRetry` never exceeds `maxAttempts` calls to the wrapped function - Circuit breaker: after `failureThreshold` consecutive failures, state is `'open'` ### Benchmarks (mitata) - `sleep(0)` call overhead (baseline async cost) - `retry` with always-succeeding fn — 0 retry overhead - `createExponentialBackoff` delay computation — 1 000 iterations - `CircuitBreaker.call` closed-state hot path — 10 000 iterations ## Impact - Adds one new package: `packages/universal/retry` - Adds one new spec capability: `retry` - No existing package consumers on genesis yet