5.0 KiB
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. (Seeopenspec/AGENTS.mdSection 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 wrappedfnfails twice then succeeds - THEN
execute(fn)returns the success value ANDbreaker.executewas 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
retryConfigAND invokesexecute(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 inspectsstateafter a failedexecute - THEN the returned
CircuitBreakerreports 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)wherefnalways rejects AND the breakerfailureThresholdis hit before retry attempts are exhausted - THEN the call rejects with
RetryError(RETRY.CIRCUIT_OPEN_AFTER_RETRY)ANDerror.causeis an instance ofCircuitBreakerError
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-memoryTracerProvider+MeterProvider - THEN 3 spans named
retry.with-circuit-breaker.executeare recorded AND theretry.with-circuit-breaker.invocationcounter 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"