@setu-ts/resilience-plugin
Circuit breaker, retry, timeout, and bulkhead. Registers an IResilienceService under
CAPABILITIES.RESILIENCE ('resilience').
Zero dependencies. wrap() composes the four patterns around any
(signal: AbortSignal) => Promise<T> in a fixed order — bulkhead → circuitBreaker → retry →
timeout → fn — and builds them once per wrap into a state-preserving closure, so the breaker and
bulkhead accumulate state across calls.
Installation
import { ResiliencePlugin } from '@setu-ts/resilience-plugin';
Usage
import { ResiliencePlugin } from '@setu-ts/resilience-plugin';
import { CAPABILITIES, type IResilienceService } from '@setu-ts/common';
app.register(
ResiliencePlugin({
defaultRetry: { limit: 3, delay: 100, backoff: 'exponential' },
}),
);
const resilience = app.services.get<IResilienceService>(CAPABILITIES.RESILIENCE);
// `retry: true` consumes the plugin default; `timeout` is per-wrap.
const callApi = resilience.wrap(() => externalApi.call(), { retry: true, timeout: 2000 });
await callApi();
Hoist the wrapped call.
wrap()builds its breaker, bulkhead and retry state ONCE, into the returned closure — so awrap()written inside a handler runs fresh on every request and its circuit breaker can never open (retry and timeout still work, which makes the mistake silent). Wrap at module or plugin scope; call per request.
Options
| Option | Type | Description |
|---|---|---|
defaultCircuitBreaker | CircuitBreakerPolicy | Consumed when a wrap sets circuitBreaker: true. |
defaultRetry | RetryPolicy | Consumed when a wrap sets retry: true. |
defaultBulkhead | BulkheadPolicy | Consumed when a wrap sets bulkhead: true. |
Setting a pattern to true with no matching plugin default throws — an unconfigured policy is a
mistake, not a silent no-op.
Errors
TimeoutError, BulkheadFullError, and CircuitOpenError are exported for instanceof handling,
and each carries an HTTP status an application running errorHandler answers with. Shedding is the
bulkhead’s purpose, so the status is part of the contract rather than an implementation detail:
| Error | Served status | The condition the caller can act on |
|---|---|---|
BulkheadFullError | 503 | The bulkhead is at capacity and shed the call — back off. |
CircuitOpenError | 503 | The breaker is failing fast on a failing dependency — back off. |
TimeoutError | 504 | The protected call did not answer within its deadline. |
None of them carries Retry-After. The bulkhead’s queue drains on the order of one protected
call’s latency, which the framework does not measure, so it holds no honest value to state; 503 is
itself the retryable signal (RFC 9110). The rate limiter’s Retry-After is the deliberate asymmetry
— a fixed window genuinely knows when it resets.
Semantics
- The circuit breaker uses a monotonic rolling failure window with an open → half-open cooldown.
timeoutcancels the attempt. The wrapped function receives anAbortSignalthat the timeout aborts when the deadline elapses, with theTimeoutErroras itsreason; the timer is cleared in afinally. Forward the signal to your I/O (fetch(url, { signal })) and the call is genuinely cancelled. Work that ignores the signal keeps running in the background while the caller’sawaitrejects — a property of the supplied function, not of the wrapper.- The bulkhead is a bounded FIFO queue; overflow rejects with
BulkheadFullError.
Exports
| Export | Kind |
|---|---|
ResiliencePlugin | function |
BulkheadFullError | class |
CircuitOpenError | class |
TimeoutError | class |
ResiliencePluginOptions | interface |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
API reference
Resilience (@setu-ts/resilience-plugin)
Composes four pure, in-process resilience patterns — circuit breaker, retry with backoff, timeout,
and bulkhead — around an arbitrary () => Promise<T>. Zero external dependencies.
Registers IResilienceService under CAPABILITIES.RESILIENCE ('resilience').
The service is stateless at the plugin level: it holds no timers, connections, or global state, so
it registers no health indicator and no onClose hook. wrap builds the pattern chain once and
returns a hardened callable; the circuit-breaker and bulkhead state persist across every invocation
of that callable and are garbage-collected with it. Resilience protects an arbitrary async function
— it is not HTTP middleware and does not itself retry HTTP requests, talk to Redis, or persist
breaker state across instances.
Exports
ResiliencePlugin— Plugin factory registering the resilience serviceResiliencePluginOptions— Plugin configuration:defaultCircuitBreaker?,defaultRetry?,defaultBulkhead?, each consumed when awrapsets the matching field totrueTimeoutError— Thrown when a call exceeds its per-attempt timeout deadline; answered504 Gateway Timeout— the framework acted as an intermediary to a protected call that did not answer in timeBulkheadFullError— Thrown when a bulkhead sheds a call (concurrency saturated, queue full); answered503 Service Unavailable— shedding is the bulkhead’s purposeCircuitOpenError— Thrown when an open circuit breaker fails fast without invoking the call; answered503 Service Unavailable— the same load-shedding semantics on behalf of a failing dependency
The three statuses are part of the contract (X32-7), and none of the three carries
Retry-After: the hint channel (ErrorResponseInit) has no header, the bulkhead’s queue drains
on the order of one protected call’s latency — which the framework does not measure — and 503 is
itself the retryable signal (RFC 9110). The rate limiter’s Retry-After is the deliberate
asymmetry: a fixed window genuinely knows when it resets.
IResilienceService— Resilience service interface (re-exported from@setu-ts/common)WrapOptions— Pattern-selection options forwrap(re-exported)CircuitBreakerPolicy—threshold,timeout(rolling failure window ms),resetTimeout(re-exported)RetryPolicy—limit,delay,backoff(re-exported)BulkheadPolicy—maxConcurrent,maxQueue?(re-exported)BackoffStrategy—'fixed' | 'exponential'(re-exported)
Registration
import { ResiliencePlugin } from '@setu-ts/resilience-plugin';
app.register(
ResiliencePlugin({
defaultCircuitBreaker: { threshold: 5, timeout: 10_000, resetTimeout: 30_000 },
defaultRetry: { limit: 3, delay: 100, backoff: 'exponential' },
defaultBulkhead: { maxConcurrent: 10, maxQueue: 20 },
}),
);
Programmatic API
import type { IResilienceService } from '@setu-ts/common';
import { CircuitOpenError, TimeoutError } from '@setu-ts/resilience-plugin';
const resilience = ctx.services.get<IResilienceService>('resilience');
// `true` uses the plugin default; a policy object overrides per-wrap.
const guarded = resilience.wrap(() => externalApi.fetchRates(), {
bulkhead: true,
circuitBreaker: true,
retry: { limit: 3, delay: 100, backoff: 'exponential' },
timeout: 2000,
});
try {
const rates = await guarded();
} catch (error) {
if (error instanceof CircuitOpenError) {
/* fail-fast: dependency is down */
}
if (error instanceof TimeoutError) {
/* attempt exceeded the 2000ms deadline */
}
}
The wrap signature and its options:
type ResilientCall<T> = (signal: AbortSignal) => Promise<T>;
type HardenedCall<T> = (signal?: AbortSignal) => Promise<T>;
interface IResilienceService {
wrap<T>(fn: ResilientCall<T>, options?: WrapOptions): HardenedCall<T>;
}
interface WrapOptions {
readonly circuitBreaker?: boolean | CircuitBreakerPolicy;
readonly retry?: boolean | RetryPolicy;
readonly timeout?: number;
readonly bulkhead?: boolean | BulkheadPolicy;
}
When multiple patterns are enabled they compose in one fixed order, outermost to innermost:
bulkhead → circuit breaker → retry → timeout → fn. A queue-full bulkhead never touches the
breaker, retry, or fn; an open breaker fails fast before any retry attempt; each retry attempt
gets its own timeout. A field set to true with no matching default* policy configured throws at
wrap time.
Hoist the wrapped call. The state-preserving closure is built once per
wrap— awrap()written inside a handler constructs a fresh breaker on every request, so the breaker never opens while retry and timeout keep working (the broken shape looks identical to the working one). Wrap at module or plugin scope and call the returned closure per request.
Cancellation
Each attempt is handed an AbortSignal. On a timeout deadline that signal is aborted with the
same TimeoutError instance the returned promise rejects with, so a timeout has one error
identity whether observed through catch or through signal.reason:
const guarded = resilience.wrap((signal) => fetch(url, { signal }), { timeout: 2000 });
await guarded(); // rejects with TimeoutError, and the fetch is aborted
The returned callable also accepts a caller-owned signal. An outer abort propagates into the protected call, stops the retry loop between attempts, and rejects a call still queued behind the bulkhead:
const controller = new AbortController();
const pending = guarded(controller.signal);
controller.abort(); // pending rejects with the caller's reason
Cancellation is cooperative: a call that ignores the signal still runs to completion, and only
the caller’s await rejects. Passing a zero-argument () => Promise<T> remains valid — it simply
cannot be cancelled.
Breaker/bulkhead state is per-process and per-wrap; there is no shared state across instances.