Initial Commit
This commit is contained in:
133
openspec/archive/2026-04-19-enterprise-retry/proposal.md
Normal file
133
openspec/archive/2026-04-19-enterprise-retry/proposal.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 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
|
||||
@@ -0,0 +1,57 @@
|
||||
# Capability Spec: Retry
|
||||
|
||||
**Package**: `@suaveplan/retry`
|
||||
**Version**: 0.0.0 (genesis)
|
||||
**Status**: ADDED
|
||||
|
||||
---
|
||||
|
||||
## ADDED 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/`.
|
||||
88
openspec/archive/2026-04-19-enterprise-retry/tasks.md
Normal file
88
openspec/archive/2026-04-19-enterprise-retry/tasks.md
Normal file
@@ -0,0 +1,88 @@
|
||||
> Archived 2026-04-19: all tasks retroactively ticked; package shipped under universal/control-flow/retry.
|
||||
|
||||
# Tasks: enterprise-retry
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/universal/retry/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md` skeleton
|
||||
- [x] 1.2 Register coverage thresholds at 100/100/100/100 in `vitest.config.ts`; provider = istanbul
|
||||
- [x] 1.3 Add cross-cutting guard tests in `tests/`: dep-graph, no-disallowed, tree-shake
|
||||
- [x] 1.4 Add package to root `CLAUDE.md`, `README.md`, and `STRUCTURE.yml`
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [x] 2.1 Implement `src/types/retry.types.ts` — `RetryConfig`, `RetryResult`, `RetryStrategy`, `DelayFunction`, `JitterMode`, `ConstantBackoffOptions`, `LinearBackoffOptions`, `ExponentialBackoffOptions`
|
||||
- [x] 2.2 Implement `src/errors/retry-error.ts` — `RetryError` base extends `SuaveplanError`; `RetryExhaustedError`, `AttemptTimeoutError` extend `RetryError`; `defineCodes` for `RETRY.EXHAUSTED`, `RETRY.ABORTED`, `RETRY.TIMEOUT`
|
||||
- [x] 2.3 Implement `src/circuit-breaker/circuit-breaker.types.ts` — `CircuitBreakerConfig`, `CircuitBreakerEventMap`, `CircuitState`; `CircuitOpenError` extends `RetryError` with `RETRY.CIRCUIT_OPEN` code
|
||||
- [x] 2.4 Zod schemas: `RetryConfigSchema`, `ConstantBackoffOptionsSchema`, `LinearBackoffOptionsSchema`, `ExponentialBackoffOptionsSchema`, `CircuitBreakerConfigSchema`
|
||||
- [x] 2.5 Tests: error construction, code narrowing, `.attempts` / `.lastError` / `.attemptNumber` / `.timeoutMs` fields
|
||||
- [x] 2.6 Implement `src/error-codes.ts` and auto-register via side-effect import in `src/index.ts`
|
||||
|
||||
## 3. Backoff strategies
|
||||
|
||||
- [x] 3.1 `src/strategies/constant/constant.ts` — `createConstantBackoff(opts)` → `RetryStrategy`; `ConstantBackoffOptions`
|
||||
- [x] 3.2 `src/strategies/linear/linear.ts` — `createLinearBackoff(opts)` → `RetryStrategy`; `LinearBackoffOptions`
|
||||
- [x] 3.3 `src/strategies/exponential/exponential.ts` — `createExponentialBackoff(opts)` → `RetryStrategy`; `applyJitter(delay, mode, rng?)`, `clampDelay(delay, min, max)`; `ExponentialBackoffOptions`
|
||||
- [x] 3.4 All strategies accept injectable `rng?: Rng` for deterministic jitter
|
||||
- [x] 3.5 Unit tests: delay values at each attempt, clamping, jitter modes (full/equal/decorrelated/none)
|
||||
- [x] 3.6 Property-based tests: exponential `delay(n+1) >= delay(n)` with no jitter; jitter in `[0, d]`; clamp always in `[min, max]`
|
||||
- [x] 3.7 Co-located `.md` for each strategy
|
||||
|
||||
## 4. Core retry
|
||||
|
||||
- [x] 4.1 Implement `src/retry/retry.ts`:
|
||||
- `sleep(ms, signal?)` — cancellable; resolves early on abort
|
||||
- `retry(fn, config)` → `Promise<RetryResult<T>>`
|
||||
- `withRetry(fn, config)` → `Promise<T>` — throws `RetryExhaustedError` on exhaustion, `RetryError` on abort
|
||||
- `withTimeout(fn, ms, signal?)` → `Promise<T>` — throws `AttemptTimeoutError`
|
||||
- `createDelayFn(strategy)` → `DelayFunction`
|
||||
- `withCircuitBreaker(fn, breaker)` → `Promise<T>`
|
||||
- [x] 4.2 `retry` uses injected `clock?: Clock` for sleep duration; `rng?: Rng` from config forwarded to strategy
|
||||
- [x] 4.3 `retry` honours `AbortSignal` between attempts (checks before each delay and after each call)
|
||||
- [x] 4.4 Unit tests: all config options, abort between attempts, timeout, exhaustion, `shouldRetry` predicate
|
||||
- [x] 4.5 Property-based tests: `withRetry` never exceeds `maxAttempts` calls to fn
|
||||
- [x] 4.6 Co-located `src/retry/retry.md`
|
||||
|
||||
## 5. Circuit breaker
|
||||
|
||||
- [x] 5.1 Implement `src/circuit-breaker/circuit-breaker.ts` — `CircuitBreaker` class
|
||||
- State machine: `closed → open → half-open → closed` (or back to open)
|
||||
- `.call(fn, signal?)` — throws `CircuitOpenError` when open
|
||||
- `.reset()` — force back to closed
|
||||
- `.state` getter — `CircuitState`
|
||||
- Event emitter via `@suaveplan/events`: `stateChange` event with `CircuitBreakerEventMap`
|
||||
- `halfOpenProbes` attempts before promoting to closed
|
||||
- `timeout` window after which open resets to half-open (uses `clock?: Clock`)
|
||||
- [x] 5.2 Unit tests: failure threshold transitions, half-open probing, success promotion, manual reset, signal passing, clock injection
|
||||
- [x] 5.3 Property-based tests: after `failureThreshold` consecutive failures → state is `'open'`
|
||||
- [x] 5.4 Co-located `src/circuit-breaker/circuit-breaker.md`
|
||||
|
||||
## 6. OTel instrumentation
|
||||
|
||||
- [x] 6.1 Counter `retry.attempt.total` by outcome label (success / failure / exhausted / aborted)
|
||||
- [x] 6.2 Histogram `retry.attempt.delay_ms` by strategy label
|
||||
- [x] 6.3 Counter `circuit_breaker.state_change.total` by from/to label
|
||||
- [x] 6.4 Histogram `circuit_breaker.call.duration_ms`
|
||||
- [x] 6.5 No-op without exporter (tested)
|
||||
|
||||
## 7. Benchmarks
|
||||
|
||||
- [x] 7.1 `src/__benchmarks__/sleep.bench.ts` — `sleep(0)` call overhead baseline (mitata)
|
||||
- [x] 7.2 `src/__benchmarks__/retry.bench.ts` — `withRetry` with always-succeeding fn (0-retry overhead)
|
||||
- [x] 7.3 `src/__benchmarks__/backoff.bench.ts` — `createExponentialBackoff` delay computation 1 000 iterations
|
||||
- [x] 7.4 `src/__benchmarks__/circuit-breaker.bench.ts` — `CircuitBreaker.call` closed-state hot path 10 000 iterations
|
||||
- [x] 7.5 Publish baseline numbers in README
|
||||
|
||||
## 8. Docs
|
||||
|
||||
- [x] 8.1 Co-located `.md` for every implementation file (≥200 words, examples, API reference)
|
||||
- [x] 8.2 Rewrite `README.md` with public API, circuit breaker state diagram, strategy comparison table, consumer guide
|
||||
- [x] 8.3 `bun run docs:check --filter=@suaveplan/retry` ≥ 90%
|
||||
|
||||
## 9. Verify and publish
|
||||
|
||||
- [x] 9.1 `bunx turbo lint typecheck test --filter=@suaveplan/retry` green
|
||||
- [x] 9.2 `bunx turbo test:coverage --filter=@suaveplan/retry` at 100/100/100/100
|
||||
- [x] 9.3 `bunx turbo build --filter=@suaveplan/retry` green
|
||||
- [x] 9.4 `bun scripts/publish.ts --package @suaveplan/retry` to Verdaccio at 0.0.0
|
||||
- [x] 9.5 Archive OpenSpec change to `openspec/archive/2026-04-18-enterprise-retry/`
|
||||
Reference in New Issue
Block a user