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);
}
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import { Observable, retry, timer, throwError } from 'rxjs';
import { RETRY_CONFIG, isRetryable, computeBackoff } from './retry.config';
@Injectable()
export class ResilientHttpService {
private readonly logger = new Logger(ResilientHttpService.name);
constructor(private readonly http: HttpService) {}
private withRetry<T>(source: Observable<AxiosResponse<T>>): Observable<AxiosResponse<T>> {
return source.pipe(
retry({
count: RETRY_CONFIG.maxRetries,
delay: (error: AxiosError, retryCount: number) => {
if (!isRetryable(error)) {
return throwError(() => error);
}
const wait = computeBackoff(retryCount);
const status = error.response?.status ?? error.code ?? 'network';
this.logger.warn(`Retry ${retryCount}/${RETRY_CONFIG.maxRetries} after ${wait}ms (${status})`);
return timer(wait);
},
}),
);
}
get<T>(url: string, config?: AxiosRequestConfig): Observable<AxiosResponse<T>> {
return this.withRetry(this.http.get<T>(url, config));
}
post<T>(url: string, body: unknown, config?: AxiosRequestConfig): Observable<AxiosResponse<T>> {
return this.withRetry(this.http.post<T>(url, body, config));
}
}
import { Body, Controller, Post } from '@nestjs/common';
import { firstValueFrom } from 'rxjs';
import { ResilientHttpService } from './resilient-http.service';
interface DispatchResult {
id: string;
accepted: boolean;
}
@Controller('webhooks')
export class WebhookController {
constructor(private readonly resilientHttp: ResilientHttpService) {}
@Post('dispatch')
async dispatch(@Body() payload: Record<string, unknown>): Promise<DispatchResult> {
const response = await firstValueFrom(
this.resilientHttp.post<DispatchResult>(
'https://partner.example.com/v1/events',
payload,
{ timeout: 3_000, headers: { 'Idempotency-Key': String(payload.eventId) } },
),
);
return response.data;
}
}
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
Share this code
Here's the card — post it anywhere.