Node.js cookbook · Beginner

Call external APIs with timeouts and retries in Node.js

Make outbound HTTP calls that never hang and recover from transient failures.

Approach

Use the built-in fetch with AbortSignal.timeout() for every call. Retry idempotent requests on network errors, 429 and 5xx with exponential backoff and jitter, respect Retry-After, and cap attempts.

Solution

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const RETRYABLE = new Set([429, 502, 503, 504]);

export async function fetchJson<T>(url: string, init: RequestInit = {}, { attempts = 3, timeoutMs = 5000 } = {}): Promise<T> {
  const method = (init.method ?? "GET").toUpperCase();
  const idempotent = ["GET", "HEAD", "PUT", "DELETE"].includes(method);

  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
      if (res.ok) return (await res.json()) as T;
      if (!idempotent || !RETRYABLE.has(res.status) || attempt >= attempts) {
        throw new Error(`HTTP ${res.status} from ${url}`);
      }
      const retryAfter = Number(res.headers.get("retry-after"));
      await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
    } catch (err) {
      const transient = err instanceof TypeError || (err as Error).name === "TimeoutError";   // network error / timeout
      if (!idempotent || !transient || attempt >= attempts) throw err;
      await sleep(backoff(attempt));
    }
  }
}

const backoff = (attempt: number) => Math.min(5000, 200 * 2 ** attempt) + Math.random() * 100;

Pitfalls

  • No timeout (Node's fetch can wait a very long time).
  • Retrying POSTs without idempotency keys.
  • Retry storms during outages — add a circuit breaker (opossum).

Variations

  • undici Agent with connection pooling and per-origin limits.
  • ky or got libraries with built-in retries.

Further reading: MDN: AbortSignal.timeout()

Reference: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static

More Node.js recipes