Retry With Exponential Backoff: 7 Errors, 2 Retries

Which API errors are worth retrying? We ran a Node.js retry client against seven failures and it retried two. The code, and the bugs that shaped it.

  • 9 min read
  • Published December 22, 2025
  • Updated October 22, 2026

Retry a failed API call only when asking again could get a different answer: a timeout on a request that’s safe to repeat, a 5xx that reflects a passing problem, or a 429 that means “too fast”. Don’t retry a 400, 401, 403 or 404, or a 429 that means you’re out of credits. Those come back the same every time, and retrying them only adds delay. We ran the retry client in this post against seven failures, five of them live responses from our own API. It retried two, and both then succeeded.

Seven failures, side by side

Here is what our API actually returned for each case, and what the client did:

CaseStatusError bodyRetried?
No API key401"unauthorized", with a message saying where the key goesNo
Parameter name misspelled (emial=)400{"missing":["email"]}No
API name misspelled (emailvalidatr)404"Unknown API 'emailvalidatr'"No
A scraper’s target site blocks bots503"The website returned HTTP 403 and could not be read."No
Client timeout on a POSTnonethe client gave up after 1 sNo
Rate limited429"Rate limit exceeded. Slow down and retry.", Retry-After: 1Yes, after 1 s
Upstream hiccup502"Bad gateway"Yes, after 268 ms

The last two came from a small local stub that fails twice and then succeeds. Getting a real rate limit means hammering a shared key, and a real 502 can’t be produced on demand. The stub’s 429 body is our edge’s real per-key message; the edge’s Retry-After is 10 seconds, shortened to 1 here to keep the demo quick. The other five are live responses.

The client

It throws an ApiError that carries the status and whether a retry could help, so the caller can tell “fix your request” from “try later”.

// api-call.js: call an API, retry only what a retry can fix
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class ApiError extends Error {
  constructor(message, { status, retryable, body }) {
    super(message);
    Object.assign(this, { status, retryable, body });
  }
}

// Decide, from one response, whether asking again could change the answer.
function classify(res, body) {
  const msg = typeof body?.error === 'string' ? body.error : JSON.stringify(body?.error ?? null);
  if (res.status === 429) {
    // Same status, two meanings: out of credits (wait for the reset) or too fast (wait seconds).
    const outOfCredits = res.headers.get('x-api-remaining-credits') === '0';
    return { retryable: !outOfCredits, msg };
  }
  if (res.status >= 500) {
    // A scraper's target site refusing us comes back as 503, and it will refuse again.
    return { retryable: !/website returned HTTP 4\d\d/i.test(msg), msg };
  }
  return { retryable: false, msg }; // 400, 401, 403, 404: fix the request, don't repeat it
}

async function apiCall(url, { method = 'GET', headers = {}, body, timeoutMs = 10000,
  retries = 3, deadlineMs = 30000, idempotent = method === 'GET' } = {}) {
  const started = Date.now();
  for (let attempt = 0; ; attempt++) {
    let res, json;
    try {
      res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) });
      json = await res.json().catch(() => null);
    } catch (err) {
      // Timeout or network failure. The server may have done the work anyway,
      // so only repeat requests that are safe to run twice.
      const e = new ApiError(`${err.name}: ${err.message}`, { status: 0, retryable: idempotent });
      if (!e.retryable || attempt >= retries) throw e;
      await backoff(attempt, null, started, deadlineMs, e);
      continue;
    }
    if (res.ok && json?.status !== 'error') return json;

    const { retryable, msg } = classify(res, json);
    const e = new ApiError(`HTTP ${res.status}: ${msg}`, { status: res.status, retryable, body: json });
    if (!retryable || attempt >= retries) throw e;
    await backoff(attempt, res.headers.get('retry-after'), started, deadlineMs, e);
  }
}

// Full jitter: a random wait between 0 and the exponential cap, so a crowd of
// clients that failed together doesn't come back together. Retry-After wins.
async function backoff(attempt, retryAfter, started, deadlineMs, lastError) {
  const wait = retryAfter ? Number(retryAfter) * 1000 : Math.random() * Math.min(8000, 500 * 2 ** attempt);
  if (Date.now() - started + wait > deadlineMs) throw lastError; // out of time: give up now, not later
  console.log(`  retry in ${Math.round(wait)} ms after ${lastError.message}`);
  await sleep(wait);
}

module.exports = { apiCall, classify, ApiError };

The run that produced the table:

success: ok {"email":"jane@gmail.com","domain":"gmail.com","username":"j (850 ms)
no key: HTTP 401: unauthorized (retryable=false, 77 ms)
typo in param: HTTP 400: {"missing":["email"]} (retryable=false, 285 ms)
unknown API: HTTP 404: Unknown API 'emailvalidatr' (retryable=false, 56 ms)
site refused: HTTP 503: The website returned HTTP 403 and could not be read. (retryable=false, 3366 ms)
timeout, POST: TimeoutError: The operation was aborted due to timeout (retryable=false, 1017 ms)
  retry in 1000 ms after HTTP 429: Rate limit exceeded. Slow down and retry.
  retry in 268 ms after HTTP 502: Bad gateway
429 then 502 (stub): ok {"ok":true} (1329 ms)

The 429 that means two things

Our API returns 429 for two unrelated reasons. One is a rate limit: you sent too much too fast, and waiting a few seconds fixes it. The other is that the key has used its monthly credits, and nothing fixes it until the renewal date. Retrying that kind just piles up rejected calls, and the job behind it fails slowly instead of loudly.

The message text differs between the two, but don’t parse it. Read the x-api-remaining-credits header: 0 means out of credits.

We found out how much that header matters from a bug of our own. Our edge decides a key is out of credits by reading that same header off responses. A helper on our side filled in a missing value with || 0, and on code paths that never counted credits, it sent back -1. The edge saw a negative balance and started answering our own main-site key with 429 “no remaining credits”, while the account was fine. The fix was to send the header only when a real count exists and to have the edge ignore it otherwise. If you build anything that reads a header, keep “no value” distinct from “zero”.

The 503 that will never succeed

A 5xx usually means “our problem, try again”. There’s one exception in our catalog. The scraping endpoints fetch someone else’s site, and when that site refuses (bot protection, a 403, a login wall), we return 503 with the site’s status in the message. That answer is about the site, not about us, and it’s stable: we tried the New York Times front page in a crawl test and got the same refusal every time.

A retry-every-5xx client asks again and waits. Some sites take a while to say no; Etsy took 24 seconds in our test, so three attempts plus backoff would have cost more than a minute to learn nothing. The classify function reads the message and stops.

Timeouts on requests that cost money

When a request times out, you don’t know whether the server finished it. For a lookup that doesn’t matter. For a POST that renders a PDF, sends an email or charges a card, repeating it can do the work twice. Our HTML-to-PDF renders take about 3 seconds; in the demo the client gave up at 1 second, and the render may well have completed and used credits anyway.

So the client retries timeouts only for requests marked idempotent, which defaults to GET. Pass idempotent: true for a POST only if running it twice is harmless.

The error that isn’t an error

The hardest failures to handle are the ones that return 200. If you misspell an optional parameter, most APIs, ours included, ignore it and use the default:

$ curl "https://api.apiverve.com/v1/passwordgenerator?lenght=40" -H "x-api-key: $KEY"
{"status":"ok", ... "passwords":[{"password":"Gfs3fJgV01PI","length":12, ...

Forty characters requested, twelve delivered, status ok. We had a much bigger version of this problem ourselves. In August 2026 an audit found 35 parameters across 19 of our APIs that the code read but the API definitions didn’t declare. Our request validation passes along only declared parameters, so all 35 were silently dropped before the code saw them. The passphrase and custom-character modes of the password generator had never worked, and no caller had ever received an error saying so. We fixed the definitions and added a script that flags any parameter the code reads but no definition declares.

No retry policy catches this. What catches it is checking that the response matches what you asked for, wherever the API echoes it back:

const { apiCall } = require('./api-call.js');
const H = { 'x-api-key': process.env.APIVERVE_API_KEY };

async function password(length) {
  const url = `https://api.apiverve.com/v1/passwordgenerator?lenght=${length}`; // note the typo
  const { data } = await apiCall(url, { headers: H });
  const got = data.passwords[0].length;
  if (got !== length) throw new Error(`asked for ${length} characters, got ${got}: check the parameter names`);
  return data.passwords[0].password;
}

password(40).then(console.log, (e) => console.log(e.message));
asked for 40 characters, got 12: check the parameter names

With the spelling fixed, it prints a 40-character password.

Backoff details that matter

Use full jitter, a random wait between zero and the exponential cap, rather than a fixed doubling. When an outage ends, every client that failed during it retries; with fixed doubling they retry in synchronised waves and knock the service over again. AWS’s architecture blog has the simulation that made this standard, and it’s worth reading once.

Let Retry-After override your own schedule. The server knows when its limit resets and you don’t. Our edge sends it on every rate-limit 429.

Set a deadline as well as a retry count. Four attempts with backoff can add up to longer than the person waiting will tolerate, and the request handler that called you has its own timeout. The client above gives up as soon as the next wait would cross 30 seconds, instead of sleeping and then failing anyway.

And log every final failure with its status and body. The silent-parameter bug above lasted as long as it did because there was never an error to log.

Keep Reading

Give your software a way to act on the world.

Scaling in production?

The same key runs from your first prototype to millions of calls — on APIVerve's rails, backed by an uptime SLA.

See pricing