Retry budgets and circuit breakers

Bound retries with an overall deadline, full-jitter backoff, Retry-After parsing in seconds or HTTP-date, a retry budget and a per-API circuit breaker.

  • 45 min
  • Advanced

The Fundamentals course gave you a retry loop: four tries, exponential backoff with jitter, honour retry-after, never retry credit exhaustion. Inside one script that is correct. Inside a fleet it can still take you down, in three ways that each have a known fix.

  1. Each attempt has a timeout, but the call as a whole does not. Four tries of 10 seconds plus 8 seconds of backoff is 48 seconds, while the user who triggered it gave up after five. The fix is an overall deadline.
  2. Every caller retries independently. When an upstream starts failing, every client multiplies its load by its retry count at the exact moment the upstream can least take it. The fix is a retry budget.
  3. Nobody remembers that the last thousand calls failed. Each new request starts optimistic, waits out its full timeout, and fails. The fix is a circuit breaker.

This lesson builds all three as small pieces, then composes them with the error classes from lesson one.

Retry amplification

Retries multiply across layers. Suppose a browser calls your API with 3 attempts, your API calls a worker service with 3 attempts, and the worker calls APIVerve with 4. If APIVerve's upstream for one endpoint is failing, one user click can produce 3 x 3 x 4 = 36 calls to it. Every one of those either costs latency or, if it succeeds partway through, credits.

Two rules keep this under control:

  • Retry at one layer only. Pick the layer closest to the failing dependency (the code that calls APIVerve) and make every layer above it fail fast. A service that calls another of your services should usually not retry at all; it should pass the error and its retryability upward.
  • Make the remaining layer's retries conditional on the fleet's state, not just on this request's history. That is the budget and the breaker.

An overall deadline

A deadline is a single timestamp computed when the operation starts. Every attempt's timeout is the smaller of its own limit and the time left, and no backoff sleep is allowed to end after the deadline.

deadline.mjs
export function makeDeadline(totalMs, now = Date.now()) {
  const at = now + totalMs;
  return {
    at,
    remaining: (t = Date.now()) => Math.max(0, at - t),
    // Per-attempt timeout: never longer than the time that is left.
    attemptTimeout: (perAttemptMs, t = Date.now()) => Math.min(perAttemptMs, Math.max(0, at - t)),
    // Is it worth sleeping `waitMs` and then trying again?
    allows: (waitMs, minAttemptMs = 500, t = Date.now()) => t + waitMs + minAttemptMs <= at,
  };
}

The minAttemptMs term in allows() is easy to miss. Sleeping until 50 ms before the deadline and then starting an attempt with a 50 ms timeout is a wasted call: it spends a rate-limit slot and cannot finish. If there is not enough time left for a realistic attempt after the wait, give up now and return the last error.

When the deadline comes from upstream (a web request with its own timeout, a queue message with a visibility window), derive yours from it with some margin rather than inventing an independent number. A worker whose message becomes visible again after 30 seconds should finish, successfully or not, well inside 30 seconds, or a second worker will start the same job.

Full jitter as a pure function

The delay formula from Fundamentals is worth turning into a function you can test. Injecting the random number instead of calling Math.random() inside makes it deterministic in tests:

backoff.mjs
/**
 * Full-jitter exponential backoff.
 * @param {number} attempt  zero-based: 0 for the wait after the first failure
 * @param {number} baseMs   ceiling for attempt 0
 * @param {number} capMs    maximum ceiling for any attempt
 * @param {number} rand     a number in [0, 1), normally Math.random()
 * @returns {number} delay in milliseconds, in [0, min(capMs, baseMs * 2 ** attempt))
 */
export function backoffDelay(attempt, baseMs, capMs, rand) {
  const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
  return rand * ceiling;
}

Working through a few values makes the shape concrete. With baseMs = 100 and capMs = 1000:

attemptbaseMs * 2 ** attemptceilingrand = 0.5 gives
010010050
1200200100
3800800400
416001000500
40about 1.1e141000500

The cap is what keeps large attempt numbers sane. In JavaScript 2 ** 1024 is Infinity, and Math.min(capMs, Infinity) is still capMs, so the function never returns NaN or overflows. Clamp the attempt anyway if you log the uncapped value.

Why full jitter rather than "exponential plus a little random"? With a small jitter on top of a fixed exponential schedule, a thousand clients that failed together still return in tight waves at 1 s, 2 s, 4 s. Full jitter spreads each wave across the entire interval from zero to the ceiling, so the load the upstream sees after an incident is roughly flat. The cost is that some individual retries come back almost immediately, which is fine: the budget below stops that from becoming a flood.

The same function in Python, for completeness:

backoff.py
def backoff_delay(attempt: int, base_ms: float, cap_ms: float, rand: float) -> float:
    return rand * min(cap_ms, base_ms * 2 ** attempt)

Python integers do not overflow, so a huge attempt produces a huge integer before min reduces it. Harmless, but clamp attempt if you call it in a loop with no upper bound.

Check yourself

backoffDelay(attempt, baseMs, capMs, rand) returns rand * min(capMs, baseMs * 2 ** attempt). What is backoffDelay(5, 200, 3000, 0.25)?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales