Agora

Declarative usage

Wrap a class method with timeout, retry and circuit-breaker policies declaratively using the @withResilience decorator — the ergonomic counterpart to wrap(...).

@withResilience(...policies) is a TypeScript method decorator that runs the decorated method through a composed resilience pipeline. It's the declarative, ergonomic counterpart to calling service.execute(wrap(...policies)) by hand — same policies, same composition, less boilerplate.

import { withResilience, timeout, retry, circuitBreaker, InMemoryResilienceStore } from '@adonis-agora/resilience'

const store = new InMemoryResilienceStore()

class PaymentClient {
  @withResilience(
    timeout(1_000),                                    // outermost
    retry({ attempts: 3 }),                            // retries the timed-out call
    circuitBreaker({ key: 'pay', store, threshold: 5, cooldownMs: 30_000 }) // innermost
  )
  async charge(amount: number): Promise<Receipt> {
    return this.http.post('/charge', { amount })
  }
}

Calling charge() now executes timeout( retry( circuitBreaker( body ) ) ), exactly as wrap(timeout(1_000), retry({ attempts: 3 }), circuitBreaker({ … })) would.

A timeout here rejects, but does not cancel

The decorator calls your method with its original arguments, which means it has nowhere to hand you the PolicyContext — so ctx.signal never reaches the method body.

For retry and circuitBreaker that changes nothing. For timeout it does: when the deadline fires, the promise charge() returned rejects with a TimeoutError, but the HTTP request inside charge keeps running to completion in the background. Nothing is aborted, and the connection is still held. The example above behaves exactly this way.

Under load that matters — a slow dependency accumulates abandoned in-flight work that the timeout no longer bounds. When you need the timeout to genuinely cancel, take the signal as a parameter and use execute instead of the decorator:

class PaymentClient {
  // the operation receives ctx, so `signal` reaches fetch and the request is aborted
  charge(amount: number) {
    return resilience.execute(policy, ({ signal }) =>
      this.http.post('/charge', { amount }, { signal })
    )
  }
}

Reach for the decorator when the policies are about retrying and failing fast, and for execute when a deadline has to reach through to the transport.

Composition order

The argument order mirrors wrap: the first policy is the outermost layer and the last sits closest to the method body. Read it top-down — the timeout bounds the entire retry loop; the breaker wraps each individual attempt.

Need a per-attempt deadline instead? Put timeout after retry in the list — @withResilience(retry({ attempts: 3 }), timeout(1_000)) gives each try its own 1s budget.

The method shape

The decorator preserves this, all arguments, and the return type:

class Greeter {
  greeting = 'hello'

  @withResilience(retry({ attempts: 2 }))
  async greet(name: string): Promise<string> {
    return `${this.greeting} ${name}` // `this` and args are intact
  }
}

Only async methods can be decorated — the policies are promise-based, so the method must return a Promise.

Providing a store

Because a method decorator runs at class-definition time, there's no per-request container to resolve a circuit-breaker store from. Pass an explicit store to any circuitBreaker policy:

import { circuitBreaker, lucidResilienceStore } from '@adonis-agora/resilience'
import db from '@adonisjs/lucid/services/db'

// the first argument is the database itself — see Stores for the accepted shapes
const store = lucidResilienceStore(db)

class InventoryClient {
  @withResilience(circuitBreaker({ key: 'inventory', store, threshold: 5, cooldownMs: 30_000 }))
  async check(sku: string): Promise<Stock> { /* … */ }
}

lucidResilienceStore(db, opts?) takes a Lucid database (or a named connection) and returns a ready store; redisResilienceStore(client, opts?) is its Redis twin. Both are covered in Stores.

Share a single store instance across every breaker that should trip together. To reuse the one your app already configured rather than building a second, take it from the service at module scope — resilience.circuitStore() for the default, or resilience.circuitStore('redis') for a named entry:

import resilience from '@adonis-agora/resilience/services/main'

const store = resilience.circuitStore()

When to reach for wrap instead

@withResilience shines for fixed policies attached to a method. When you need policies built per-request (e.g. a tenant-scoped breaker key) or resolved from the container, call ResilienceService.execute with wrap(...) directly. The two are interchangeable — the decorator is sugar over the same engine.

On this page