Aviary

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 }): Policy

Rejects 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 }): Policy

Re-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 herd

There's no constant() helper — a fixed delay is just a function:

retry({ attempts: 5, backoff: () => 250 }); // always 250ms

circuitBreaker

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;
}): Policy

Tracks 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

ErrorThrown byCarries
TimeoutErrortimeoutms — the deadline that elapsed
BrokenCircuitErrorcircuitBreakerkey — the open circuit's key

Both extend Error with a stable name, so instanceof checks work across module boundaries.

On this page