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

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. (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"