# 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(fn: () => Promise): Promise` 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` 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"`