Service & Config
Register named policies in config/resilience.ts, let the provider bind a singleton ResilienceService, and run policies + inspect circuits through the container.
The AdonisJS surface is three things: config/resilience.ts where you declare named policies + the store, the provider that binds a singleton ResilienceService from that config, and the ResilienceService itself for running policies imperatively and inspecting circuits.
There are two ways to reach a policy from application code: build one inline with the policy functions (wrap(timeout(…), retry(…))) and hand it to ResilienceService.execute(policy, op), or register it by name under policies in the config and run it with ResilienceService.execute('name', op). Only the named form gets the service's event sink wired in for you — see Integrations.
The provider
node ace configure @adonis-agora/resilience registers @adonis-agora/resilience/resilience_provider in adonisrc.ts. Nothing is copied into your providers/ directory — the provider ships with the package, and it binds ResilienceService in the container as a singleton.
What that means in practice:
- The config is read lazily. The binding is built the first time something resolves
ResilienceService(or imports theservices/mainsingleton), which is after the config phase — soconfig/resilience.tsis fully available, and an app that never touches resilience never pays for it. - Resolving the service is asynchronous. Building the stores means importing driver packages, so the container factory is
async. Resolve it withawait app.container.make(ResilienceService), or just import@adonis-agora/resilience/services/main, which does that for you.@inject()handles it too. - Every configured store is built, once. Each entry in
storesis built during that first resolve and reused for the life of the process. See Stores for why that decides which peer dependencies you must install. - A missing
defaultfails loudly. Ifdefaultnames a store that isn't in thestoresmap, the resolve throwsconfig.default is "x", but config.stores.x is not definedinstead of silently falling back to the in-memory store. - An explicit
storestill wins. A fully-constructedResilienceStorepassed asstoretakes precedence overdefault/stores; with neither, the service uses an in-process in-memory store.
The config
config/resilience.ts is typed by defineConfig:
interface ResilienceConfig {
default?: string // name of the store (a key of `stores`) the breaker uses
stores?: Record<string, StoreProvider> // named stores, built with the `stores` factory
store?: ResilienceStore // explicit store — wins over default/stores; else InMemory
policies?: Record<string, () => Policy> // named policies for ResilienceService.execute
emit?: boolean // default: true — emit diagnostics events
eventEmitter?: EventEmitterLike // mirror events to an EventEmitter2-style emitter
}import { defineConfig, stores, wrap, timeout, retry, exponential } from '@adonis-agora/resilience'
export default defineConfig({
default: 'memory',
stores: {
memory: stores.memory(),
},
policies: {
payments: () => wrap(timeout(2_000), retry({ attempts: 3, backoff: exponential(100) })),
},
})For a distributed store, point default at a Lucid or Redis store built with the stores factory. Every store you list is built when the service is first resolved, and the Lucid / Redis stores import their peer dependency as they are built — so list only the ones whose peers you have installed:
import { defineConfig, stores, wrap, timeout, retry, exponential } from '@adonis-agora/resilience'
export default defineConfig({
default: 'redis',
stores: {
memory: stores.memory(),
redis: stores.redis({ connection: 'main' }), // @adonisjs/redis + ioredis
},
policies: {
payments: () => wrap(timeout(2_000), retry({ attempts: 3, backoff: exponential(100) })),
},
})See Stores for the Lucid and Redis drivers and migrations.
emit: true (default) routes every state transition to @adonis-agora/diagnostics on the agora:resilience:* channel — a no-op when diagnostics isn't installed. Pass eventEmitter to also mirror them onto an EventEmitter2-style emitter. See Integrations.
The service
Import the singleton from @adonis-agora/resilience/services/main to run policies imperatively and to inspect or reset circuits. It resolves the same container-bound ResilienceService the provider registers. Wrap the outbound call to a flaky dependency — here a payment gateway — in a service, and thread signal into the client so a timeout actually cancels the request:
import resilience from '@adonis-agora/resilience/services/main'
import { timeout } from '@adonis-agora/resilience'
import { primaryGateway, backupGateway } from '#services/gateways'
import type Order from '#models/order'
export default class PaymentGatewayService {
// run the named `payments` policy from config around the outbound charge…
async charge(order: Order) {
return resilience.execute('payments', ({ signal }) =>
fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
body: JSON.stringify({ amount: order.total, currency: 'usd' }),
signal,
}).then((res) => res.json())
)
}
// …or an inline policy for a quick liveness probe. Built here at the call site,
// so it runs fine but emits no events — see the note below.
async ping() {
return resilience.execute(timeout(1_000), ({ signal }) =>
fetch('https://api.stripe.com/v1/health', { signal })
)
}
// fall back through an ordered list of gateways — first success wins
async settle(order: Order) {
return resilience.failover({
targets: [primaryGateway, backupGateway],
run: (gateway, { signal }) => gateway.settle(order, { signal }),
})
}
// inspect the payments circuit (status / failures / openUntil)
async health() {
return resilience.circuit('payments').snapshot()
}
}A controller injects that service with @inject() and turns an open circuit into a real HTTP response instead of a 500 — fail fast with 503 when the gateway is down:
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import { BrokenCircuitError } from '@adonis-agora/resilience'
import PaymentGatewayService from '#services/payment_gateway_service'
import Order from '#models/order'
@inject()
export default class PaymentsController {
constructor(private payments: PaymentGatewayService) {}
async store({ params, response }: HttpContext) {
const order = await Order.findOrFail(params.id)
try {
const receipt = await this.payments.charge(order)
return response.ok(receipt)
} catch (error) {
// circuit is open — the gateway is down, so short-circuit with a 503
if (error instanceof BrokenCircuitError) {
return response.serviceUnavailable({ message: 'Payments are temporarily unavailable' })
}
throw error
}
}
}router.post('orders/:id/pay', [PaymentsController, 'store'])Inline policies are silent
execute() accepts a name or a ready-made Policy, and the two differ in one way that isn't visible at the call site: only a named policy is built by the service, so only a named policy gets the service's event sink. A policy you construct yourself — the timeout(1_000) above, or a module-level wrap(…) — emits no diagnostics and reaches no eventEmitter mirror unless you pass it an explicit onEvent. See Integrations.
Prefer injecting ResilienceService directly? @inject() resolves the same container-bound singleton — handy when a service holds no other state:
import { inject } from '@adonisjs/core'
import { ResilienceService, wrap, timeout, retry, exponential } from '@adonis-agora/resilience'
// an inline policy — no `config/resilience.ts` entry required (and, being built here,
// no event sink either: add `onEvent` to the policies if you want it observable)
const inventoryPolicy = wrap(timeout(2_000), retry({ attempts: 3, backoff: exponential(100) }))
@inject()
export default class InventoryService {
constructor(private resilience: ResilienceService) {}
check(sku: string) {
return this.resilience.execute(inventoryPolicy, ({ signal }) =>
fetch(`https://inventory.internal/stock/${sku}`, { signal }).then((res) => res.json())
)
}
}| Method | Purpose |
|---|---|
execute(name | policy, op) | run a named policy (resolved from config) or an inline Policy |
failover(opts) | the failover primitive, wired to the service's event sink |
circuitStore(name?) | the configured store — the default one with no argument, or the entry under that key in stores |
circuit(key).snapshot() | read the current CircuitSnapshot (status, failures, openUntil?) |
circuit(key).reset() | force a circuit back to closed |
circuitStore() is how you point a hand-built breaker at a store from your config, instead of constructing a second one that tracks its own, separate circuits:
import resilience from '@adonis-agora/resilience/services/main'
import { circuitBreaker } from '@adonis-agora/resilience'
// the store `default` points at
circuitBreaker({ key: 'payments', store: resilience.circuitStore(), threshold: 5, cooldownMs: 30_000 })
// a specific entry from config.stores — this is the only way to reach a non-default store
circuitBreaker({ key: 'search', store: resilience.circuitStore('redis'), threshold: 3, cooldownMs: 10_000 })An unknown name throws Unknown resilience store "x"., and execute('unknown') throws Unknown resilience policy "unknown". if the name isn't registered. The service also exposes its configured store and sink as readonly properties — store is the same object circuitStore() returns.
Declarative usage
Wrap a class method with timeout, retry and circuit-breaker policies declaratively using the @withResilience decorator — the ergonomic counterpart to wrap(...).
Stores
Share circuit-breaker state across instances with a config-driven ResilienceStore — in-memory by default, or Lucid (SQL) / Redis drivers selected in config/resilience.ts that coordinate the half-open probe atomically.