Files
suaveplan/openspec/changes/retry-circuit-breaker-wrapper/specs/retry/spec.md
2026-05-27 23:29:53 +01:00

77 lines
5.0 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: retry (retry-circuit-breaker-wrapper)
**Package**: `@suaveplan/retry`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: `withCircuitBreaker` composition factory
The package SHALL expose `withCircuitBreaker(options: WithCircuitBreakerOptions): WithCircuitBreakerHandle` where `WithCircuitBreakerOptions = { breakerConfig: CircuitBreakerConfig; retryConfig?: RetryConfig }`. The factory SHALL construct a fresh `CircuitBreaker` from `breakerConfig` and return a handle exposing two methods: `execute<T>(fn: () => Promise<T>): Promise<T>` and `getCircuitBreaker(): CircuitBreaker`.
When `retryConfig` is supplied, `execute(fn)` SHALL run `retry(() => breaker.execute(fn), retryConfig)` and return the inner `result.value` so callers receive `T` directly rather than a `RetryResult<T>` wrapper.
When `retryConfig` is omitted, `execute(fn)` SHALL delegate directly to `breaker.execute(fn)` (no retry).
#### Scenario: retry composes inside the breaker
- **WHEN** a caller invokes `withCircuitBreaker({ breakerConfig: { failureThreshold: 5, resetTimeoutMs: 30000 }, retryConfig: { maxAttempts: 3, strategy: 'exponential', baseDelayMs: 10 } })` AND the wrapped `fn` fails twice then succeeds
- **THEN** `execute(fn)` returns the success value AND `breaker.execute` was called 3 times AND the breaker's failure counter recorded 2 failures + 1 success
#### Scenario: retry-only path when retryConfig omitted
- **WHEN** a caller omits `retryConfig` AND invokes `execute(fn)` against a function that fails
- **THEN** `breaker.execute(fn)` is called exactly once AND the rejection propagates without retry
#### Scenario: getCircuitBreaker returns the live handle
- **WHEN** a caller invokes `getCircuitBreaker()` AND inspects `state` after a failed `execute`
- **THEN** the returned `CircuitBreaker` reports the up-to-date state (`closed` / `open` / `half-open`) reflecting the failures observed
**Verified by:** `src/compose/with-circuit-breaker/with-circuit-breaker.test.ts::"retries inside breaker boundary and unwraps result.value"` AND `src/compose/with-circuit-breaker/with-circuit-breaker.test.ts::"runs without retry when retryConfig omitted"` AND `src/compose/with-circuit-breaker/with-circuit-breaker.test.ts::"getCircuitBreaker returns the live breaker handle"`
---
### Requirement: Breaker-open during retry surfaces structured error
When the inner `breaker.execute` throws a `CircuitBreakerError` because the breaker opens during a retry pass, the wrapper SHALL convert the propagated rejection into `RetryError` with code `RETRY.CIRCUIT_OPEN_AFTER_RETRY`. The original `CircuitBreakerError` SHALL be set as the `cause` of the `RetryError`. Callers can `instanceof RetryError` AND inspect `.cause` to programmatically distinguish "all attempts exhausted" from "breaker opened mid-retry".
#### Scenario: breaker opens during retry
- **WHEN** a caller invokes `execute(fn)` where `fn` always rejects AND the breaker `failureThreshold` is hit before retry attempts are exhausted
- **THEN** the call rejects with `RetryError(RETRY.CIRCUIT_OPEN_AFTER_RETRY)` AND `error.cause` is an instance of `CircuitBreakerError`
**Verified by:** `src/compose/with-circuit-breaker/with-circuit-breaker.spec-conformance.test.ts::"throws RetryError(RETRY.CIRCUIT_OPEN_AFTER_RETRY) with CircuitBreakerError cause when breaker opens mid-retry"`
---
### Requirement: OTel observability for composed execution
`withCircuitBreaker(...).execute(fn)` SHALL open an OTel span named `retry.with-circuit-breaker.execute` for each invocation. The span SHALL nest under any active span supplied by the caller. The package SHALL increment a counter `retry.with-circuit-breaker.invocation` once per `execute` call regardless of outcome.
#### Scenario: span and counter emitted on every execute call
- **WHEN** a caller invokes `execute(fn)` 3 times under an in-memory `TracerProvider` + `MeterProvider`
- **THEN** 3 spans named `retry.with-circuit-breaker.execute` are recorded AND the `retry.with-circuit-breaker.invocation` counter equals 3
**Verified by:** `src/compose/with-circuit-breaker/with-circuit-breaker.spec-conformance.test.ts::"emits retry.with-circuit-breaker.execute span and increments invocation counter"`
---
### Requirement: Subpath export
The package SHALL expose the new module at the `./compose/with-circuit-breaker` subpath in `package.json::exports`. Importing `withCircuitBreaker` from that subpath SHALL NOT pull in unrelated `@suaveplan/retry` modules.
#### Scenario: subpath import resolves
- **WHEN** a caller writes `import { withCircuitBreaker } from "@suaveplan/retry/compose/with-circuit-breaker"`
- **THEN** TypeScript resolves the import
**Verified by:** `src/compose/with-circuit-breaker/with-circuit-breaker.spec-conformance.test.ts::"subpath ./compose/with-circuit-breaker resolves and tree-shakes"`
<!-- markdownlint-enable MD024 -->