nestjs-resilient-client
v0.3.0
Published
Zero-configuration resilience and transient-fault-handling HTTP Client based on official @nestjs/axios library
Keywords
Readme
Drop in replacement for @nestjs/axios HttpService with retries, circuit breakers, bulkheads, timeout, fallbacks and other resilience patterns integrated.
Quick Start • Resilience Patterns • Usage • Configuration Strategies • API Reference • Special Thanks
Zero-configuration resilience and transient-fault-handling HTTP client based on the official @nestjs/axios HttpService with a cockatiel-based resilience policy stack.
The
cockatiellibrary is a great reimplementation of the famous Polly library for the JS/TS ecosystem, but unfortunately it has an overcomplicated API and lacks a default preset that can fulfill most services' retry needs out of the box. This library does exactly that. You can simply replace the default@nestjs/axiosHttpServicewithRestClientand you will get a fully resilient HTTP client out of the box.
Features
- Zero-configuration resilience — pragmatic default resilience preset, suitable for the majority of workloads.
- Composable resilience pipeline — retry, circuit breaker, bulkhead, and fallback policies can be enabled independently and are wrapped in a single deterministic order.
- RxJS-based traffic shaping — opt-in
deduplication,rateLimiter, andthrottlingpolicies implemented as pure RxJS operators on the underlyingHttpServiceObservable. - Pluggable authentication — AuthModule accepts custom
AuthStrategy(Bearer, Basic, etc.), allowing you to define custom authentication approaches for APIs that not support regular auth tokens. - Hookable lifecycle — every client extends
HookableHttpService, soonInvoke/onReturn/onErrorcallbacks let you transform requests, integrate observability or other custom logic without boilerplate for each method type. - Idempotency-aware retries — only safe HTTP methods (
GET,HEAD,OPTIONSby default) are retried on 5xx / network errors. Cancellations and SSL/cert failures are excluded from retry. - Highly customizable — fine-grained control over each resilience policy.
- Promises-based API — all methods return plain Promises, not Observables. Despite that RXJS is great, people and LLMs much better at writing and reading Promises, rather than Observables.
- Easy to use — All clients implement regular axios interface, so you can use them as a drop-in replacement for
@nestjs/axiosHttpService. With only difference, that you no longer need to add.toPromise()orfirstValueFrom()to get the result.
Quick Start
Install library:
npm i nestjs-resilient-clientAdd module:
import { RestModule } from 'nestjs-resilient-client'
@Module({
imports: [
RestModule,
],
exports: [RestModule],
})
export class CatalogModule {}Use client in service:
import { RestClient } from 'nestjs-resilient-client'
@Injectable()
export class CatalogService {
constructor(private readonly client: RestClient) {}
async getProduct(id: string) {
// exposes regular axios interface
// retried up to three times on 5xx / network errors
const response = await this.client.get<Product>(`https://api.example.com/products/${id}`)
return response.data
}
async createProduct(product: Product) {
// do not retried by default, because it can be no idempotent, and not safe to retry.
const response = await this.client.post<Product>(`https://api.example.com/products`, product)
return response.data
}
}Resilience Patterns
The default configuration assumes the upstream API is healthy until it is not, and only retries genuinely idempotent requests on transient failures. By default, only reactive resilience patterns are enabled, with reasonable exceptions — for example, a timeout on an obviously too-long request. On top of that, the retry mechanism works based on the type and status of requests. It retries only idempotent requests: GET, HEAD, OPTIONS, and only for 5xx status codes. While PUT and DELETE are also considered idempotent in a properly implemented RESTful API, in reality they usually are not. This default strategy is called "Conservative".
Other presets "RESTfull" and "Low Quality" trade off retry aggressiveness against the trust you place in the upstream API.
Presets are based on years of development experience of the authors, rather than theoretical best practices. As a result, they are pragmatic and should work as you expect, without the need to tweak them for "bad" upstream APIs.
Reactive Resilience Patterns
Reactive policies engage after a failure response has been received.
- Retry — Retry request on failures. Supports: fine-grained control over retry conditions, static and exponential backoff retries on failures.
- Circuit Breaker — Stop execution for a period of time after a failure threshold has been reached. Supports: conditional, sampling, count, or consecutive breakers. Configurable half-open recovery window. Allow to configure Stop/Wait strategies.
Proactive Resilience Patterns
Proactive policies engage before a failure to manage load.
- Bulkhead — Limits the number of concurrent calls to the service. Supports semaphore-based concurrency cap with optional queue.
- Fallback — Return predefined response on failures. Supports: graceful-degradation value or factory invoked on policy-handled failures fallback.
- Timeout — Cancel request after a certain amount of time. Supports: cooperative and aggressive strategies.
- Deduplication — Reuse existing request if it is already in flight. As soon as the request completes or errors, the promise is resolved, so sequential calls always trigger a fresh request.
- Rate Limiter — Decrease the rate of requests to the upstream using either a token-bucket (burstable) or leaky-bucket (constant rate) strategy.
- Throttling — Limit the number of requests to the upstream to a fixed sliding window (e.g. "no more than N per minute"). Excess requests wait in queue and are emitted in the next window.
Usage
For requests that do not require authentication, RestModule.registerAsync is the shortest path to a fully resilient RestClient. It internally registers HttpModule with the supplied axios configuration (baseURL, timeout, default headers, …) and wires the RestClient provider for you. When resilience is omitted, the CONSERVATIVE preset is applied.
import { RestModule, ResilencePresets } from 'nestjs-resilient-client'
@Module({
imports: [
RestModule.registerAsync({
useFactory: () => ({
// Forwarded verbatim to the internally-registered HttpModule.
axios: {
baseURL: 'https://api.example.com',
},
// Optional. Default is CONSERVATIVE preset.
resilience: ResilencePresets.RESTFULL,
}),
}),
],
exports: [RestModule],
})
export class CatalogModule {}RestModule exports RestClient, so any provider in CatalogModule (or modules that import it) can inject RestClient directly:
import { RestClient } from 'nestjs-resilient-client'
@Injectable()
export class CatalogService {
constructor(private readonly client: RestClient) {}
// Resolves to https://api.example.com/products/42
async getProduct(id: string) {
const response = await this.client.get<Product>(`/products/${id}`)
return response.data
}
}The factory accepts the same inject/imports keys as any NestJS dynamic module, so axios and resilience configuration can be sourced from ConfigService or any other DI provider:
RestModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
axios: { baseURL: config.get('API_BASE_URL') },
}),
})Configuration Strategies
The CONSERVATIVE preset is the default. If it is not suitable for you, you can configure resilience pipeline manually, or use one of the following presets:
Conservative (default)
Reasonable assumptions about an API that mostly follows REST but makes common mistakes.
- Timeout is 60 seconds.
GET,HEAD,OPTIONSare retried up to 3 times on 5xx and network errors with exponential backoff.PUT,DELETE,PATCH,POSTare NOT retried.- Sampling circuit breaker with 60 seconds half-open recovery. Enabled only if during 1 minute all requests to service failed with 5xx status code.
Restfull
Trust the upstream API to honour REST idempotency on PUT and DELETE.
- Timeout is 10 seconds.
GET,HEAD,OPTIONS,PUT,DELETEare retried up to 3 times on 5xx and network errors with exponential backoff.PATCH,POSTare NOT retried.- Sampling circuit breaker with 60 seconds half-open recovery. Enabled only if during 1 minute all requests to service failed with 5xx status code.
Low Quality
Almost identical to CONSERVATIVE preset, but with longer timeout.
- Timeout is 180 seconds (3 minutes).
GET,HEAD,OPTIONSare retried up to 3 times on 5xx and network errors with exponential backoff.PUT,DELETE,PATCH,POSTare NOT retried.- Sampling circuit breaker with 60 seconds half-open recovery. Enabled only if during 1 minute all requests to service failed with 5xx status code.
Timeout Precedence
Two timeout channels coexist in the stack: the axios-level timeout (applied to every individual HTTP call by axios itself) and the resilience-level timeout field on ResilanceConfig (applied per attempt by cockatiel's TimeoutPolicy, wrapped INSIDE retry). RestModule.registerAsync (and AuthRestModule.registerAsync) reconcile the two channels at module-construction time using the following rule:
| axios.timeout | opts.resilience | Resulting resilience timeout |
| --------------- | ----------------- | ---------------------------- |
| undefined | any | opts.resilience unchanged (caller had no opinion) |
| 0 | any | opts.resilience unchanged (axios 0 means "disabled") |
| > 0 | undefined | preset timeout stripped — axios drives the deadline |
| > 0 | defined | opts.resilience unchanged (explicit user override preserved) |
Concretely:
import { RestModule } from 'nestjs-resilient-client'
@Module({
imports: [
RestModule.registerAsync({
useFactory: () => ({
axios: {
baseURL: 'https://api.example.com',
// axios.timeout WINS: the CONSERVATIVE preset's 60 s per-attempt timeout is
// stripped before RestClient is constructed, so the only deadline in effect
// is axios's 5 s. A request to a 6 s upstream fails after ~5 s with
// `ECONNABORTED` — no library-level timeout fires earlier or later.
timeout: 5_000
// resilience omitted — axios timeout will be used instead
},
}),
}),
],
})
export class FastAxiosTimeoutModule {}
RestModule.registerAsync({
useFactory: () => ({
axios: { timeout: 5_000 },
// Explicit user resilience.timeout WINS even when axios.timeout is also set:
// the request fails after ~1 s (the resilience timeout fires first).
resilience: { timeout: 1_000 },
}),
})
RestModule.registerAsync({
useFactory: () => ({
axios: { timeout: 0 },
// axios.timeout: 0 is the documented "disabled" sentinel — it does NOT
// trigger the strip rule, so the explicit resilience.timeout: 1_500 is
// honoured. A 3 s upstream request fails after ~1 500 ms.
resilience: { timeout: 1_500 },
}),
})| The fromHttpService delegation hook does NOT run this reconciliation — there is no axios.timeout to reconcile against in that path, so the caller's resilience is honoured verbatim.
Bare RestClient (no auth, manual wiring)
If you already have an HttpService provider (for example shared across multiple modules), construct RestClient directly. It accepts a HttpService (from @nestjs/axios) and an optional ResilanceConfig. When the config is omitted, RestClient falls back to the CONSERVATIVE preset.
import { HttpModule, HttpService } from '@nestjs/axios'
import { RestClient } from 'nestjs-resilient-client'
@Module({
imports: [HttpModule],
providers: [
{
provide: RestClient,
useFactory: (http: HttpService) =>
new RestClient(http),
inject: [HttpService],
},
],
exports: [RestClient],
})
export class CatalogModule {}Inject RestClient anywhere and call any axios verb. Each call goes through the composed resilience policy:
import { RestClient } from 'nestjs-resilient-client'
@Injectable()
export class CatalogService {
constructor(private readonly client: RestClient) {}
async getProduct(id: string) {
const response = await this.client.get<Product>(`/products/${id}`)
return response.data
}
}Building a resilience config from scratch
Compose only the policies you need. Unspecified fields are omitted from the pipeline entirely:
import { HttpModule, HttpService } from '@nestjs/axios'
import { RestClient, type ResilanceConfig } from 'nestjs-resilient-client'
import { isAxiosError } from 'axios'
const customConfig: ResilanceConfig<unknown> = {
retry: {
maxAttempts: 5,
// Constant 200 ms delay between every attempt
backoff: 200,
// Only retry on 5xx or network errors (no response at all)
shouldRetry: (error) =>
isAxiosError(error) && (!error.response || error.response.status >= 500),
},
circuitBreaker: {
// Open the breaker after 3 consecutive failures
breaker: 3,
// Allow one probe request after 30 s
halfOpenAfter: 30_000,
},
bulkhead: {
// At most 10 concurrent requests; queue up to 20 more
limit: 10,
queue: 20,
},
// Opt-in RxJS traffic shaping (composed deduplication → rateLimiter → throttling).
deduplication: {},
rateLimiter: {
strategy: 'token-bucket',
capacity: 10,
refillRatePerSec: 5,
},
throttling: {
requestsPerInterval: 100,
intervalMs: 60_000,
},
}
@Module({
imports: [
RestModule.registerAsync({
useFactory: () => ({
axios: {
baseURL: 'https://api.example.com',
},
resilience: customConfig,
}),
}),
],
exports: [RestClient],
})
export class DataModule {}Deduplication
When several callers issue the same logical request concurrently, deduplication shares a single in-flight Observable subscription so the upstream sees exactly one network call. The cache entry is evicted as soon as the source completes or errors, so sequential calls always trigger a fresh request. The default cache key is ${verb}:${args.url ?? args.config.url ?? ''}; supply key to customise (e.g. include a tenant header).
import type { ResilanceConfig } from 'nestjs-resilient-client'
const dedupeConfig: ResilanceConfig<unknown> = {
deduplication: {
// Optional: include a tenant header in the cache key so requests for
// different tenants do not collide.
key: (verb, args) => {
const tenant = (args.config.headers as Record<string, string> | undefined)?.['X-Tenant'] ?? ''
return `${tenant}:${verb}:${args.url ?? args.config.url ?? ''}`
},
},
}Rate Limiter
Smooths the outbound emission rate using either a token-bucket (burstable) or leaky-bucket (constant rate) strategy. 'token-bucket' allows bursts up to capacity, then sustains refillRatePerSec requests per second. 'leaky-bucket' emits at exactly refillRatePerSec regardless of arrival pattern.
import type { ResilanceConfig } from 'nestjs-resilient-client'
// Allow short bursts of up to 10 requests, then sustain 5 requests/sec.
const tokenBucketConfig: ResilanceConfig<unknown> = {
rateLimiter: {
strategy: 'token-bucket',
capacity: 10,
refillRatePerSec: 5,
},
}
// Strict 2 requests/sec regardless of arrival pattern.
const leakyBucketConfig: ResilanceConfig<unknown> = {
rateLimiter: {
strategy: 'leaky-bucket',
capacity: 1,
refillRatePerSec: 2,
},
}Throttling
Caps the number of emissions allowed within a fixed sliding window (e.g. "no more than 100 requests per minute"). Excess emissions wait for a slot in the next window. Throttling differs from rate-limiting in that it enforces a hard ceiling over a fixed-duration window rather than smoothing cadence over time.
import type { ResilanceConfig } from 'nestjs-resilient-client'
// No more than 100 requests per minute.
const throttlingConfig: ResilanceConfig<unknown> = {
throttling: {
requestsPerInterval: 100,
intervalMs: 60_000,
},
}Authenticated client
For static API tokens, use
RestModuledirectly withaxios.headers.Authorization. For dynamic credentials (token refresh, OAuth flows, anything whereisAuthenticated()can becomefalse), useAuthRestModulewith a class implementingAuthStrategy.
Static API token via RestModule
When the credential is a long-lived API token (or any value the application never has to refresh), the simplest path is to set axios.headers.Authorization on the axios config that RestModule forwards to the internally-registered HttpModule. Every outbound request inherits the header automatically.
import { ConfigModule, ConfigService } from '@nestjs/config'
import { RestModule, RestClient } from 'nestjs-resilient-client'
@Module({
imports: [
RestModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
axios: {
baseURL: 'https://api.example.com',
// Forwarded verbatim to the internally-registered HttpModule;
// every RestClient request inherits this header by default.
headers: { Authorization: `Bearer ${config.get('API_TOKEN')}` },
},
}),
}),
],
exports: [RestClient],
})
export class CatalogModule {}Authenticated client — Bearer token
The AuthStrategy interface declares four methods that together own the full session lifecycle:
authenticate(client: RestClient): Promise<void>— performs the handshake; theclientargument is a fully resilientRestClient, so auth requests reuse the same resilience policy stack as application calls.isAuthenticated(): Promise<boolean>— gates whether the next request needs to re-authenticate.extendRequest(config: AxiosRequestConfig): AxiosRequestConfig— applies the credentials to a request config (must not mutate the input).invalidate(): Promise<void>— drops the current session so the next request triggers a fresh handshake; called byAuthRestClientafter a 401.
import {
AuthRestModule,
RestClient,
type AuthStrategy,
} from 'nestjs-resilient-client'
import type { AxiosRequestConfig } from 'axios'
// Strategy classes are full DI citizens — `@Injectable()` enables
// constructor injection of any provider in the module scope.
@Injectable()
class BearerTokenStrategy implements AuthStrategy {
private token?: string
private expiresAt = 0
constructor(@Inject(ConfigService) private readonly config: ConfigService) {}
async authenticate(client: RestClient): Promise<void> {
// `client` is a fully resilient RestClient — auth requests reuse the
// same resilience policy stack (retry, circuit breaker, …) as app calls.
const response = await client.post<{ access_token: string, expires_in: number }>(
this.config.getOrThrow('AUTH_TOKEN_URL'),
{
grant_type: 'client_credentials',
client_id: this.config.getOrThrow('AUTH_CLIENT_ID'),
client_secret: this.config.getOrThrow('AUTH_CLIENT_SECRET'),
},
)
this.token = response.data.access_token
// Subtract 60 s so the session is refreshed before it actually expires.
this.expiresAt = Date.now() + response.data.expires_in * 1_000 - 60_000
}
async isAuthenticated(): Promise<boolean> {
return this.token !== undefined && Date.now() < this.expiresAt
}
extendRequest(config: AxiosRequestConfig): AxiosRequestConfig {
return {
...config,
headers: { ...(config.headers ?? {}), Authorization: `Bearer ${this.token}` },
}
}
async invalidate(): Promise<void> {
this.token = undefined
this.expiresAt = 0
}
}
@Module({
imports: [
AuthRestModule.registerAsync({
// Synchronous: passed to NestJS DI as a class token, registered via
// `useClass` self-binding so it can resolve its own constructor deps.
strategy: BearerTokenStrategy,
// Async: runtime data only — axios config, optional resilience, optional hooks.
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
axios: { baseURL: config.getOrThrow('API_BASE_URL') },
}),
}),
],
})
export class AppModule {}AuthRestModule will inject AuthRestClient into any service. Each request method automatically:
- Calls
BearerTokenStrategy.isAuthenticated(), and if it isfalse, callsBearerTokenStrategy.authenticate()(single request, concurrent callers share the same request). - Augments the request via
BearerTokenStrategy.extendRequest(config) - On a single HTTP 401 response, calls
BearerTokenStrategy.invalidate(), then re-authenticatesBearerTokenStrategy.authenticate(), and retries the underlying request once.
import { AuthRestClient } from 'nestjs-resilient-client'
@Injectable()
export class OrdersService {
constructor(private readonly client: AuthRestClient) {}
async listOrders() {
// automatically authenticate if needed
const response = await this.client.get<Order[]>('/orders')
return response.data
}
}Hooks
Every client (RestClient, AuthRestClient, and the standalone HookableHttpService) accepts an optional hooks?: HooksConfig constructor argument. Hooks bracket every dispatched method invocation with three optional callbacks:
onInvoke(method, args)— runs BEFORE the underlying transport call. Return a newInvokeArgscarrier to replace the args used for dispatch (e.g. attach a correlation header, rewrite the URL); returnundefined(orPromise<undefined>) for passthrough.onReturn(verb, args, response)— runs AFTER a successful response. Return a newAxiosResponseto replace the response handed to the caller (e.g. redact a sensitive field); returnundefinedfor passthrough.onError(verb, args, error)— runs WHEN the transport (or any inner policy) throws. Return anAxiosResponseto suppress the error and resolve with the substituted response (graceful degradation); returnundefined/voidto rethrow the original error.
Every hook may be async — return values are awaited, so token lookups, instrumentation flushes, and async transformations integrate naturally. undefined is the universal "passthrough" sentinel; any other value (including null) is treated as a substitute.
Hooks run INSIDE the resilience pipeline. Every retry attempt re-invokes the hook lifecycle — a retried request observes hook-transformed args on every attempt rather than just the first. This invariant is what makes onInvoke usable for per-attempt concerns like generating a fresh correlation ID per retry.
The HooksConfig lives on RestModuleOptions (and AuthRestModuleOptions, which extends it) so hooks can be wired through the standard DI factory:
import { isAxiosError } from 'axios'
import { RestModule, type HooksConfig } from 'nestjs-resilient-client'
const hooks: HooksConfig = {
// Attach a correlation ID to every outgoing request — re-invoked per retry
// attempt because hooks run INSIDE the resilience pipeline.
onInvoke(_verb, args) {
return {
...args,
config: {
...args.config,
headers: {
...(args.config.headers ?? {}),
'X-Correlation-Id': crypto.randomUUID(),
},
},
}
},
// Redact a sensitive field before the response leaves the client.
onReturn(_verb, _args, response) {
if (typeof response.data === 'object' && response.data !== null && 'secret' in response.data) {
return { ...response, data: { ...(response.data as object), secret: '[REDACTED]' } }
}
return undefined // passthrough — keep the original response
},
// Suppress 404 errors by substituting an empty payload.
onError(_verb, _args, error) {
if (isAxiosError(error) && error.response?.status === 404) {
return { ...error.response, data: null }
}
return undefined // passthrough — rethrow the original error
},
}
@Module({
imports: [
RestModule.registerAsync({
useFactory: () => ({
axios: { baseURL: 'https://api.example.com' },
hooks,
}),
}),
],
})
export class CatalogModule {}Behaviour notes
- 401 retry — on a single HTTP 401,
AuthRestClientcallsstrategy.invalidate()via theAuthProcessor, re-authenticates, re-extends the original args (so a staleAuthorizationheader from the failed attempt is replaced), then replays the call exactly once against the underlying transport (which itself runs through the resilience pipeline). A second 401 — or any non-401 error — is rethrown without re-invalidating the strategy. - Concurrent authentication — any number of concurrent authentication attempts result in a single real request to the authentication service. The
AuthProcessorenforces single-flight semantics via@DeduplicateInflighton the underlyingstrategy.authenticate(client)call. - Cancellation — the cockatiel and user
signalis forwarded into axios. So retries, timeouts, and circuit-breakers can cancel in-flight axios calls cooperatively.
API Reference
Classes
RestClient
Resilient HTTP client. Wraps @nestjs/axios's HttpService and runs every request through a composed cockatiel IPolicy.
new RestClient(httpService: HttpService, config?: ResilanceConfig<unknown>, hooks?: HooksConfig)httpService— the upstream@nestjs/axiosHttpService.config— optional resilience configuration; defaults toResilencePresets.CONSERVATIVE.hooks— optionalHooksConfiglifecycle (onInvoke/onReturn/onError) forwarded toHookableHttpService.
Public methods mirror HttpService:
request, get, delete, head, post, put, patch, postForm, putForm, patchForm. Each returns a Promise<AxiosResponse<...>>. The axiosRef getter exposes the underlying AxiosInstance for adapter-level interop.
AuthRestClient
Authenticated facade over RestClient. Composes a RestClient (which owns the resilience policy stack) with an AuthProcessor (which orchestrates the authentication lifecycle by delegating to a user-supplied AuthStrategy).
new AuthRestClient(restClient: RestClient, processor: AuthProcessor, hooks?: HooksConfig)Same public method surface as RestClient. Each call authenticates first, augments the request via extendRequest, and recovers from a single 401. The optional hooks argument is forwarded to HookableHttpService exactly like on RestClient.
AuthProcessor
Orchestrates the per-request authentication lifecycle by delegating every session-state query to an injected AuthStrategy class instance. Holds no cached authResult — the strategy itself owns the session state, and the processor only enforces cross-cutting concerns (single-flight handshake via @DeduplicateInflight, pre-flight gating, 401 invalidation).
new AuthProcessor(strategy: AuthStrategy, client: RestClient)Public methods:
isAuthenticated(): Promise<boolean>— awaited delegation tostrategy.isAuthenticated(). The processor caches nothing; the strategy is the single source of truth.authenticateIfNeeded(): Promise<void>— short-circuits whenawait isAuthenticated()resolves withtrue; otherwise triggers a freshstrategy.authenticate(client)handshake. Concurrent callers share a single in-flight handshake (single-flight via@DeduplicateInflight).extendRequest(config: AxiosRequestConfig): AxiosRequestConfig— pure delegation tostrategy.extendRequest(config). The strategy contract forbids mutating the input.clearAuth(): Promise<void>— pure delegation tostrategy.invalidate(),await isAuthenticated()resolves withfalseand the nextauthenticateIfNeeded()triggers a fresh handshake. Used byAuthRestClient's 401 retry path.
AuthRestModule
NestJS dynamic module that wires AUTH_MODULE_OPTIONS, the user's AuthStrategy class (registered via useClass self-binding), RestClient, AuthProcessor, and AuthRestClient. Owns its own HttpModule.registerAsync(...) lifecycle so the consumer-supplied axios config flows through the same path as RestModule.registerAsync. Exports AuthRestClient and RestClient.
AuthRestModule.registerAsync(options: {
strategy: Type<AuthStrategy>
useFactory: (...args: unknown[]) => Promise<AuthRestModuleOptions> | AuthRestModuleOptions
inject?: unknown[]
imports?: unknown[]
}): DynamicModulestrategy— class implementingAuthStrategy; used as both the DI token and theuseClassvalue (self-binding). Must carry@Injectable()if it has constructor dependencies, otherwise NestJS cannot resolve constructor parameter metadata and will throw at module bootstrap.useFactory— async factory returningAuthRestModuleOptions(runtime data: optionalaxios, optionalresilience, optionalhooks).inject/imports— forwarded to the internalHttpModule.registerAsyncandRestModule.fromHttpServicecalls so the factory can depend on any provider exported fromimports(e.g.ConfigService).
RestModule
NestJS dynamic module that wires REST_MODULE_OPTIONS, an internally-managed HttpModule (registered with the consumer-supplied axios config), and RestClient. Exports RestClient. Also valid as a static import (imports: [RestModule]) — the class-level @Module({...}) populates default providers (HttpService + RestClient with the CONSERVATIVE preset) so the zero-config path requires no factory call.
RestModule.registerAsync(options: {
useFactory: (...args: unknown[]) => Promise<RestModuleOptions> | RestModuleOptions
inject?: unknown[]
imports?: unknown[]
}): DynamicModuleThe factory returns a RestModuleOptions object:
interface RestModuleOptions {
/** Axios configuration forwarded to the internally-registered `HttpModule`. */
axios?: HttpModuleOptions
/** Optional resilience policy stack; defaults to the CONSERVATIVE preset when absent. */
resilience?: ResilanceConfig<unknown>
/** Optional HooksConfig lifecycle forwarded to RestClient. */
hooks?: HooksConfig
}AuthRestModule.registerAsync accepts a parallel AuthRestModuleOptions shape that extends RestModuleOptions directly — every option supported by the unauthenticated module (axios, resilience, hooks) is also available on the authenticated module. Note that the strategy class token is no longer carried inside this options object — it is passed synchronously as the top-level strategy field on registerAsync's argument so the DI container can register it before the async factory resolves.
interface AuthRestModuleOptions extends RestModuleOptions {}HttpModule is registered asynchronously inside the module, so consumers do not need to import it themselves. The inject and imports keys are forwarded to the internal HttpModule.registerAsync call, so the factory can depend on any provider exported from imports (e.g. ConfigService).
RestModule also exposes a lower-level delegation hook for advanced consumers that already manage their own HttpService lifecycle:
RestModule.fromHttpService(options: {
useFactory: (...args: unknown[]) => Promise<RestFromHttpServiceOptions> | RestFromHttpServiceOptions
inject?: unknown[]
imports?: unknown[]
}): DynamicModule
interface RestFromHttpServiceOptions {
/** Pre-resolved `@nestjs/axios` transport handed to the constructed `RestClient`. */
httpService: HttpService
/** Optional resilience policy stack; defaults to the CONSERVATIVE preset when absent. */
resilience?: ResilanceConfig<unknown>
/** Optional HooksConfig lifecycle forwarded to RestClient. */
hooks?: HooksConfig
}Unlike registerAsync, fromHttpService does not register an internal HttpModule — the caller supplies a pre-resolved HttpService directly. Use it when a sibling module already constructs and exports the transport (for example AuthRestModule itself delegates RestClient construction to this method) and you want the canonical new RestClient(httpService, resilience, hooks) wiring without spinning up a second axios instance. Prefer registerAsync for the typical case where the module should own the HttpModule registration.
Configuration types
AuthStrategy
Strategy that owns the full lifecycle of an authentication session: performing the initial handshake, reporting whether the cached credentials are still valid, attaching them to outgoing requests, and invalidating the session when it has been rejected by the upstream service. Implementations are user-supplied classes registered with AuthRestModule via strategy: Type<AuthStrategy>.
interface AuthStrategy {
/**
* Performs the authentication handshake and stores the resulting session
* state on the implementation instance itself. Called inside a
* single-flight wrapper, so concurrent callers share one in-flight
* handshake. The `client` is a fully resilient `RestClient`, so auth
* requests reuse the same resilience policy stack as application calls.
*/
authenticate(client: RestClient): Promise<void>
/**
* Resolves with `true` while the current credentials are still considered
* valid. Must resolve with `false` when no session has been established yet
* or after `invalidate()` has been called. Asynchronous so implementations
* can consult persisted credential stores or remote token-introspection
* endpoints without blocking the dispatch path on synchronous I/O.
*/
isAuthenticated(): Promise<boolean>
/**
* Returns a NEW `AxiosRequestConfig` with authentication material applied.
* MUST NOT mutate the input — callers may reuse the original config.
*/
extendRequest(config: AxiosRequestConfig): AxiosRequestConfig
/**
* Drops the current session so the next request triggers a fresh
* `authenticate()` call. Resolves once the session has been dropped, so
* implementations may flush persisted credentials or await a remote
* sign-out endpoint. Invoked by `AuthRestClient` after the upstream
* service rejects a request with HTTP 401.
*/
invalidate(): Promise<void>
}HooksConfig
Lifecycle hooks that wrap a single verb invocation. Every field is optional. undefined (or Promise<undefined>) is the universal passthrough sentinel; any other return value is treated as a substitute. See the Hooks section above for usage examples.
interface HooksConfig {
onInvoke?: (
verb: HttpVerb,
args: InvokeArgs,
) => InvokeArgs | Promise<InvokeArgs> | undefined | Promise<undefined>
onReturn?: (
verb: HttpVerb,
args: InvokeArgs,
response: AxiosResponse,
) => AxiosResponse | Promise<AxiosResponse> | undefined | Promise<undefined>
onError?: (
verb: HttpVerb,
args: InvokeArgs,
error: unknown,
) => AxiosResponse | Promise<AxiosResponse> | undefined | Promise<undefined> | void
}ResilanceConfig<T, S = void, R = unknown>
Composable resilience configuration. Each field is optional; an empty config produces a NoopPolicy.
interface ResilanceConfig<T, S = void, R = unknown> {
retry?: RetryConfig<T, S>
circuitBreaker?: CircuitBreakerConfig
bulkhead?: BulkheadConfig
fallback?: FallbackConfig<R>
timeout?: number | TimeoutConfig
deduplication?: DeduplicationConfig
rateLimiter?: RateLimiterConfig
throttling?: ThrottlingConfig
}Sub-types RetryConfig, CircuitBreakerConfig, BulkheadConfig, FallbackConfig, TimeoutConfig, DeduplicationConfig, RateLimiterConfig, and ThrottlingConfig are exported as type-only aliases; see src/client/resilance.config.ts for the full field-level documentation. The timeout field accepts either a bare millisecond duration (cooperative cancellation) or a full TimeoutConfig for finer-grained control.
Example — retry only:
import { ExponentialBackoff } from 'cockatiel'
import type { ResilanceConfig } from 'nestjs-resilient-client'
const retryOnlyConfig: ResilanceConfig<unknown> = {
retry: {
maxAttempts: 3,
backoff: new ExponentialBackoff(),
},
}Example — retry + circuit breaker:
import { ExponentialBackoff } from 'cockatiel'
import type { ResilanceConfig } from 'nestjs-resilient-client'
const retryWithBreakerConfig: ResilanceConfig<unknown> = {
retry: {
maxAttempts: 3,
backoff: new ExponentialBackoff(),
},
circuitBreaker: {
// Open after 5 consecutive failures; probe again after 30 s.
breaker: 5,
halfOpenAfter: 30_000,
},
}Example — full pipeline (cockatiel + RxJS):
import type { ResilanceConfig } from 'nestjs-resilient-client'
const fullyConfigured: ResilanceConfig<unknown, void, string> = {
retry: {
maxAttempts: 3,
// Array-based backoff: 100 ms, then 500 ms, then 500 ms for all further attempts.
backoff: [100, 500],
},
circuitBreaker: {
// SamplingBreaker: open when ≥ 50 % of requests over 30 s window fail.
breaker: { threshold: 0.5, duration: 30_000, minimumRps: 10 },
halfOpenAfter: 60_000,
},
bulkhead: {
limit: 20,
queue: 40,
},
fallback: {
valueOrFactory: 'service-unavailable',
},
// Per-attempt deadline: every retry attempt is bounded by its own 30 s window.
timeout: 30_000,
// RxJS traffic shaping (composed deduplication → rateLimiter → throttling).
deduplication: {},
rateLimiter: {
strategy: 'token-bucket',
capacity: 10,
refillRatePerSec: 5,
},
throttling: {
requestsPerInterval: 100,
intervalMs: 60_000,
},
}HookableHttpService
HookableHttpService — accepts an optional hooks?: HooksConfig and overrides dispatch to bracket super.dispatch(...) with onInvoke / onReturn / onError. Constructor signature: (httpService, hooks?, rxjsPipeline?).
Subclasses extend whichever layer matches the responsibility you need:
import type { AxiosResponse } from 'axios'
import type { HttpService } from '@nestjs/axios'
import {
HookableHttpService,
type HttpVerb,
type InvokeArgs,
} from 'nestjs-resilient-client'
/**
* Logging facade that records every verb invocation and the resulting status.
* Wraps a fully resilient `RestClient`-compatible transport, so all calls still
* go through the underlying retry / circuit-breaker / bulkhead pipeline.
*/
@Injectable()
export class LoggingRestClient extends HookableHttpService {
constructor(client: HttpService) {
super(client)
}
protected override async dispatch<T = unknown>(
verb: HttpVerb,
args: InvokeArgs,
): Promise<AxiosResponse<T>> {
const startedAt = Date.now()
try {
// Forward to the wrapped transport. `super.dispatch` runs the hooks
// lifecycle (onInvoke / onReturn / onError) and then the default
// `callUnderlying` path.
const response = await super.dispatch<T>(verb, args)
const elapsedMs = Date.now() - startedAt
console.log(
`[http] ${verb.toUpperCase()} ${args.url ?? args.config.url} -> ${response.status} (${elapsedMs} ms)`,
)
return response
}
catch (error) {
const elapsedMs = Date.now() - startedAt
console.error(
`[http] ${verb.toUpperCase()} ${args.url ?? args.config.url} failed after ${elapsedMs} ms`,
error,
)
throw error
}
}
}Special Thanks
Library essentially is a re-implementation of the following libraries for NestJS:
- Resilience4j
- Polly — and the great article on resilience patterns
- Failsafe
- Tenacity
- Gobreaker
Patterns implementation is based on or uses the following libraries:
