typescript 88 lines · 3 tabs

Exponential-Backoff Retry Interceptor Around NestJS HttpService

Shared by codesnips Aug 2026
3 tabs
import { AxiosError } from 'axios';

export const RETRY_CONFIG = {
  maxRetries: 4,
  baseDelayMs: 200,
  maxDelayMs: 5_000,
};

export const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);

const RETRYABLE_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EAI_AGAIN']);

export function isRetryable(error: AxiosError): boolean {
  if (!error.response) {
    // No response means a network/timeout error.
    return error.code ? RETRYABLE_CODES.has(error.code) : true;
  }
  return RETRYABLE_STATUS.has(error.response.status);
}

export function computeBackoff(attempt: number): number {
  const exponential = RETRY_CONFIG.baseDelayMs * 2 ** (attempt - 1);
  const capped = Math.min(exponential, RETRY_CONFIG.maxDelayMs);
  const jitter = 0.5 + Math.random() * 0.5;
  return Math.floor(capped * jitter);
}
3 files · typescript Explain with highlit

This snippet shows how a NestJS service that talks to a flaky third-party API can survive transient failures by wrapping every outbound request with an exponential-backoff retry policy. The core idea is that not all HTTP failures are equal: a 429 or 503, a connection reset, or a timeout is worth retrying, while a 400 or 404 is a permanent client error that no amount of retrying will fix. The code encodes that distinction and adds jitter so a fleet of instances does not retry in lockstep and stampede a recovering upstream.

In retry.config.ts, the tunables are collected into a small RETRY_CONFIG object and a RETRYABLE_STATUS set. Keeping maxRetries, baseDelayMs, and maxDelayMs in one place makes the policy easy to reason about and to override per environment. The isRetryable helper inspects an AxiosError: network-level errors (ECONNRESET, ETIMEDOUT, no response) are always retried, and HTTP responses are retried only when their status is in the retryable set.

The heart of the pattern lives in resilient-http.service.ts. It wraps the injected HttpService and pipes each Observable through RxJS's retry operator using its delay callback. The callback receives the error and the 1-based retryCount; it throws immediately for non-retryable errors, and otherwise returns a timer whose duration is computed by computeBackoff. That backoff is baseDelayMs * 2 ** (attempt - 1), clamped to maxDelayMs, then multiplied by a random jitter factor between 0.5 and 1.0. Returning a timer observable — rather than a raw number — is what lets RxJS schedule the delay declaratively. Because the whole thing is still an Observable, callers get lazy, cancellable requests for free.

webhook.controller.ts demonstrates the consumer side: it calls resilientHttp.post and awaits it via firstValueFrom, unwrapping the Axios data. The controller stays oblivious to retries — the resilience is a cross-cutting concern owned by the service.

A key trade-off to understand is idempotency: retrying a non-idempotent POST can cause duplicate side effects upstream, so this policy is safest for reads or for endpoints that accept an idempotency key. Capping maxDelayMs and total attempts bounds worst-case latency, which matters when the call sits on a request path rather than in a background job.


Related snips

Share this code

Here's the card — post it anywhere.

Exponential-Backoff Retry Interceptor Around NestJS HttpService — share card
Link copied