@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 a wrap() 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

OptionTypeDescription
defaultCircuitBreakerCircuitBreakerPolicyConsumed when a wrap sets circuitBreaker: true.
defaultRetryRetryPolicyConsumed when a wrap sets retry: true.
defaultBulkheadBulkheadPolicyConsumed 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:

ErrorServed statusThe condition the caller can act on
BulkheadFullError503The bulkhead is at capacity and shed the call — back off.
CircuitOpenError503The breaker is failing fast on a failing dependency — back off.
TimeoutError504The 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.
  • timeout cancels the attempt. The wrapped function receives an AbortSignal that the timeout aborts when the deadline elapses, with the TimeoutError as its reason; the timer is cleared in a finally. 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’s await rejects — a property of the supplied function, not of the wrapper.
  • The bulkhead is a bounded FIFO queue; overflow rejects with BulkheadFullError.

Exports

ExportKind
ResiliencePluginfunction
BulkheadFullErrorclass
CircuitOpenErrorclass
TimeoutErrorclass
ResiliencePluginOptionsinterface

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 service
  • ResiliencePluginOptions — Plugin configuration: defaultCircuitBreaker?, defaultRetry?, defaultBulkhead?, each consumed when a wrap sets the matching field to true
  • TimeoutError — Thrown when a call exceeds its per-attempt timeout deadline; answered 504 Gateway Timeout — the framework acted as an intermediary to a protected call that did not answer in time
  • BulkheadFullError — Thrown when a bulkhead sheds a call (concurrency saturated, queue full); answered 503 Service Unavailable — shedding is the bulkhead’s purpose
  • CircuitOpenError — Thrown when an open circuit breaker fails fast without invoking the call; answered 503 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 for wrap (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 — a wrap() 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.


Agent card · TypeDoc API reference