Agora

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>, parent?: PolicyContext): 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. The optional parent argument is how wrap threads context (and the abort signal) from an outer layer into an inner one — you rarely pass it by hand. rootContext() produces the never-aborting context used at the top of a chain.

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 '@adonis-agora/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; onEvent?: EventSink }): 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 '@adonis-agora/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.

The timeout policy also forwards aborts from its parent context, so an outer timeout cancels an inner one.

Pass onEvent to observe the deadline being hit — the sink receives a timeout event carrying ms. Like every policy, a timeout you build yourself is silent unless you wire it: see which policies get the service's sink.

retry and backoff

retry(opts: { attempts: number; backoff?: Backoff; clock?: Clock; onEvent?: EventSink }): 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. The backoff defaults to () => 0 (no wait), and each attempt sees its 0-based index on ctx.attempt. The inter-attempt delay honours the parent's AbortSignal, so a wrapping timeout cuts a pending backoff short.

Backoff is just (attempt: number) => number. The package ships exponential:

exponential(baseMs: number, opts?: { jitter?: boolean; factor?: number; maxMs?: number }): Backoff
import { retry, exponential } from '@adonis-agora/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

retry({ attempts: 10, backoff: exponential(500, { maxMs: 30_000 }) })
// 500ms, 1s, 2s, 4s, 8s, 16s, then capped at 30s for every remaining attempt

maxMs caps the computed delay before jitter is applied, so a jittered delay never exceeds the cap either. It is optional and off by defaultexponential() does not impose an implicit ceiling (e.g. 30s) on your behalf, even though that would be the "safer" default for most callers. Two reasons: it's a standalone exported helper used outside retry() too, and quietly changing an already-deployed delay curve on a routine upgrade is worse than leaving it uncapped. In practice, once attempts climbs past a handful, always pass maxMs — an uncapped exponential curve reaches minutes-long waits within 8–10 attempts at the default factor: 2:

retry({ attempts: 12, backoff: exponential(200, { maxMs: 30_000, jitter: true }) })

Pass onEvent to observe the re-attempts: the sink receives a retry event before each one, carrying attempt (the 1-based number of the upcoming try) and error (the failure that triggered it).

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  // required — pick where breaker state lives
  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 '@adonis-agora/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 breaker has no implicit, module-wide store: store is a required option, so it is always obvious which circuits share state. Pass the same store instance to every breaker that should share state (or wire one store into config/resilience.ts and reuse it).

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 (and throws immediately if targets is empty).

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 '@adonis-agora/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}`),
})

Each failover step emits a failover event carrying target (stringified), index, and error — but only to a sink you pass as onEvent. Calling the exported failover() directly, as above, emits nowhere: it takes no ambient sink, so nothing reaches diagnostics or your EventEmitter mirror. Call it through ResilienceService.failover to get the service's sink wired in for you, or pass onEvent yourself. onFailover fires either way.

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 = wrap(retry({ attempts: 3, backoff: exponential(100) }), timeout(1_000))
await policy.execute(() => callOnce())
// retry( timeout( callOnce ) ) — each attempt has its own 1s deadline

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