Files
2026-05-27 23:29:53 +01:00

134 lines
5.4 KiB
Markdown

# 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<RetryResult<T>>` — core retry primitive
- `withRetry(fn, config)` → `Promise<T>` — throws on exhaustion
- `withCircuitBreaker(fn, breaker)` → `Promise<T>` — wraps fn with circuit breaker
- `withTimeout(fn, ms, signal?)` → `Promise<T>` — wraps fn with deadline
- `sleep(ms, signal?)` → `Promise<void>` — 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<T>` — `{ 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