Retry & Outbound Resilience
Outbound calls from Node fail transiently. Combine timeouts, retries with backoff, and circuit breakers so one slow vendor does not stall your API.
Recipe
Quick-reference recipe card - copy-paste ready.
import got from "got";
const client = got.extend({
timeout: { request: 8_000 },
retry: {
limit: 3,
methods: ["GET", "PUT", "HEAD", "DELETE", "OPTIONS", "TRACE"],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
calculateDelay: ({ attemptCount }) => Math.min(1000 * 2 ** attemptCount, 10_000) + Math.random() * 200,
},
hooks: {
beforeRetry: [(err, retryCount) => {
console.warn({ msg: "got_retry", retryCount, err: err.message });
}],
},
});When to reach for this:
- REST partners with occasional 503
- SDK disabled auto-retry and you own policy
- Background workers retrying safe reads
Working Example
// src/http/partner-client.ts
import got, { type Got } from "got";
function buildClient(): Got {
return got.extend({
prefixUrl: process.env.PARTNER_API_URL,
headers: { Authorization: `Bearer ${process.env.PARTNER_TOKEN}` },
timeout: { request: 8_000 },
retry: {
limit: 3,
methods: ["GET"],
statusCodes: [429, 500, 502, 503, 504],
calculateDelay: ({ attemptCount }) => 500 * 2 ** attemptCount + Math.random() * 100,
},
});
}
export const partnerClient = buildClient();
// POST with manual idempotent retry
export async function createPartnerJob(idempotencyKey: string, body: unknown) {
const maxAttempts = 3;
let attempt = 0;
while (attempt < maxAttempts) {
try {
return await partnerClient.post("jobs", {
json: body,
headers: { "Idempotency-Key": idempotencyKey },
retry: { limit: 0 },
}).json();
} catch (err) {
attempt++;
if (!isRetryable(err) || attempt >= maxAttempts) throw err;
await sleep(500 * 2 ** attempt);
}
}
}
function isRetryable(err: unknown): boolean {
if (!got.isHTTPError(err)) return true; // network
const code = err.response?.statusCode ?? 0;
return code >= 500 || code === 429;
}What this demonstrates:
- GET retries via got defaults
- POST retries only with idempotency key and custom loop
- Network errors distinguished from 4xx business errors
Deep Dive
Axios Interceptor Pattern
import axios from "axios";
import axiosRetry from "axios-retry";
const api = axios.create({ baseURL: process.env.PARTNER_API_URL, timeout: 8000 });
axiosRetry(api, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (err) => axiosRetry.isNetworkOrIdempotentRequestError(err) || err.response?.status === 429,
});axios-retrypackage mirrors got policy- See got vs axios in Essential Libraries
What Not to Retry
| Status | Retry? |
|---|---|
| 400 Bad Request | No - fix payload |
| 401/403 | No - fix credentials |
| 404 | No - wrong resource |
| 409 Conflict | No - unless idempotent upsert |
| 429 | Yes with backoff respecting Retry-After |
| 503 | Yes limited attempts |
Circuit Breaker Integration
import CircuitBreaker from "opossum";
const breaker = new CircuitBreaker(callPartner, {
timeout: 10_000,
errorThresholdPercentage: 50,
resetTimeout: 30_000,
});
breaker.fallback(() => ({ status: "degraded" }));- See Circuit Breakers
Total Deadline Budget
const deadline = Date.now() + 15_000;
while (attempts--) {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error("deadline_exceeded");
await client.get("x", { timeout: { request: remaining } });
}- User-facing HTTP request should not outlive client SLA
Gotchas
- Retry POST without idempotency - double create. Fix: key header or no retry.
- Infinite retry on 429 - amplifies vendor overload. Fix: cap attempts, honor
Retry-After. - Retry storm after regional outage - all pods retry sync. Fix: jitter + breaker.
- Logging full error bodies - PII leak. Fix: status + request id only.
- Default SDK retries + app retries - 9 attempts surprise. Fix: disable one layer.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Queue retry only | User not waiting on sync path | User needs immediate partner response |
| No retry, fail fast | Optional enrichment | Payment authorization |
| Temporal activity retry | Long workflow orchestration | Simple fetch |
| Hedged requests | Extreme tail latency | Idempotent read only, doubles load |
FAQs
got or axios in 2026?
Both maintained. got ESM-native; axios ubiquitous. Pick one per repo.
Retry in request path?
Max 1-2 quick retries inside user request. Heavy retry belongs in worker.
Stripe SDK retries?
Stripe retries idempotent requests automatically - do not double-wrap blindly.
DNS flapping?
Counts as network error - retry with backoff; alert if persistent.
Timeout vs retry?
Each attempt needs timeout; total wall clock also capped.
Idempotent PUT?
Safe to retry if resource key stable. POST create is not without key.
Testing retries?
MSW/nock return 503 twice then 200; assert call count and final success.
Fetch API retries?
Native fetch has no retry - wrap with got/axios or custom helper.
NestJS HttpService?
RxJS retry operator - same idempotency rules apply.
Global axios defaults?
Avoid implicit global retry - per-integration client instances clearer.
Related
- Integrations Basics - service boundary
- Circuit Breakers - stop calling failing deps
- Timeouts Everywhere - deadlines
- got & axios - client choice
- Integration Failure Playbook - outage comms
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.