Policies
The policy engine — timeout, retry with backoff, circuit breaker, wrap composition, and the list-oriented failover primitive, plus the typed errors they throw.
A policy is a small object with one method:
interface Policy {
execute<T>(op: (ctx: PolicyContext) => Promise<T>): Promise<T>;
}
interface PolicyContext {
signal: AbortSignal; // abort cooperatively on timeout
attempt: number; // 0-based retry attempt
}Every built-in policy is a factory that returns one. They share the same shape, so they compose.
wrap — compose policies
wrap(...policies) builds one policy from many. The first argument is the outermost layer; the last sits closest to your operation:
import { wrap, timeout, retry, circuitBreaker } from '@dudousxd/nestjs-resilience';
const policy = wrap(timeout(2_000), retry({ attempts: 3 }), circuitBreaker({ /* … */ }));
// execution order: timeout( retry( circuitBreaker( op ) ) )Read it top-down: the timeout bounds the entire retry loop; the breaker wraps each individual attempt. Order matters — put timeout inside the retry instead if you want a per-attempt deadline (see the recipe below).
timeout
timeout(ms: number, opts?: { clock?: Clock }): PolicyRejects with a TimeoutError if the operation hasn't settled within ms, and aborts the PolicyContext.signal so cooperating work can stop:
import { timeout, TimeoutError } from '@dudousxd/nestjs-resilience';
try {
await timeout(5_000).execute(({ signal }) => fetch(url, { signal }));
} catch (err) {
if (err instanceof TimeoutError) console.warn(`timed out after ${err.ms}ms`);
}A timeout can only cancel work that listens to the AbortSignal. Always thread ctx.signal into your HTTP client / DB driver — otherwise the timeout rejects the promise but the underlying request keeps running.
retry and backoff
retry(opts: { attempts: number; backoff?: Backoff; clock?: Clock }): PolicyRe-runs the operation up to attempts times, waiting backoff(attempt) milliseconds between tries. After the last failure it rethrows the final error. attempts: 3 means three total tries.
Backoff is just (attempt: number) => number. The package ships exponential:
import { retry, exponential } from '@dudousxd/nestjs-resilience';
retry({ attempts: 4, backoff: exponential(100) });
// waits 100ms, 200ms, 400ms between the four attempts (factor defaults to 2)
retry({ attempts: 4, backoff: exponential(100, { jitter: true, factor: 3 }) });
// 100ms·3^n, each randomized to 50–100% to spread out a thundering herdThere's no constant() helper — a fixed delay is just a function:
retry({ attempts: 5, backoff: () => 250 }); // always 250mscircuitBreaker
circuitBreaker(opts: {
key: string;
store: ResilienceStore;
threshold: number; // consecutive failures before opening
cooldownMs: number; // how long to stay open before probing
halfOpenMax?: number; // concurrent probes allowed in half-open (default 1)
onEvent?: EventSink;
}): PolicyTracks failures per key in a store. After threshold failures it opens — every call fails fast with a BrokenCircuitError instead of hammering a dead dependency. After cooldownMs it goes half-open and lets a single probe through; a success closes it, a failure re-opens it.
import { circuitBreaker, BrokenCircuitError, InMemoryResilienceStore } from '@dudousxd/nestjs-resilience';
const breaker = circuitBreaker({
key: 'inventory-api',
store: new InMemoryResilienceStore(),
threshold: 5,
cooldownMs: 30_000,
});
try {
await breaker.execute(() => inventory.check(sku));
} catch (err) {
if (err instanceof BrokenCircuitError) {
// err.key === 'inventory-api' — short-circuited, don't even try
}
}The in-memory store keeps breaker state per process. To trip the breaker fleet-wide — one instance opens, all instances skip — pass a distributed store and the half-open probe is coordinated atomically. See Stores.
failover
failover() walks an ordered list of targets, running each through an optional per-target policy, and returns the first success. When every target fails it throws the last error.
failover<TTarget, R>(opts: {
targets: TTarget[];
run: (target: TTarget, ctx: PolicyContext) => Promise<R>;
policy?: (target: TTarget) => Policy;
onFailover?: (target: TTarget, error: unknown, index: number) => void;
onEvent?: EventSink;
}): Promise<R>Unlike the other policies, failover runs immediately and returns the result — it's a function, not a Policy. Give each target its own breaker + timeout so a known-dead target is skipped instantly:
import { failover, wrap, timeout, circuitBreaker, InMemoryResilienceStore } from '@dudousxd/nestjs-resilience';
const store = new InMemoryResilienceStore();
const sms = await failover({
targets: [twilio, vonage, sns],
run: (provider, { signal }) => provider.send(message, { signal }),
policy: (provider) =>
wrap(circuitBreaker({ key: `sms:${provider.id}`, store, threshold: 5, cooldownMs: 30_000 }), timeout(8_000)),
onFailover: (provider, err, i) => logger.warn(`sms #${i} ${provider.id} failed: ${err}`),
});failover powers the multi-provider transport in @dudousxd/nestjs-notifications-resilience — try Twilio, fall back to Vonage, then SNS, with a breaker per provider.
Recipe: per-attempt timeout
Putting timeout inside retry bounds each try rather than the whole loop, so a single slow attempt is retried instead of failing the lot:
const policy = retry({ attempts: 3, backoff: exponential(100) });
await policy.execute((ctx) => wrap(timeout(1_000)).execute(() => callOnce(ctx.signal)));
// or simply: wrap(retry({ attempts: 3 }), timeout(1_000))Errors
| Error | Thrown by | Carries |
|---|---|---|
TimeoutError | timeout | ms — the deadline that elapsed |
BrokenCircuitError | circuitBreaker | key — the open circuit's key |
Both extend Error with a stable name, so instanceof checks work across module boundaries.
Getting Started
Install nestjs-resilience, wrap a flaky call with a composed policy, then register the module and use it through dependency injection.
Decorators & Module
Wrap provider methods with @Timeout / @Retry / @CircuitBreaker, register named policies through ResilienceModule, and run them via ResilienceService.