0Pricing
Node.js Backend Development Bootcamp · 课时

超时、重试与带抖动的指数退避

为每次出站调用设置上限,并在不放大依赖服务负载的前提下重试暂时性故障。

超时、重试与带抖动的指数退避 是 CoddyKit 上的免费 Node.js Backend Development Bootcamp 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Node.js Backend Development Bootcamp 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Why Bound Every Outbound Call

In a Node.js backend, every call that leaves your process (HTTP request, database query, message broker publish) is a place where you can hang forever. A slow dependency does not just delay one request, it pins an event-loop tick, holds a socket, and keeps a connection-pool slot busy.

  • Unbounded latency cascades: one stuck downstream call multiplies into thousands of stuck inbound requests.
  • Resource exhaustion: sockets, file descriptors, and pool connections leak while you wait.
  • No SLA without a deadline: you cannot promise a p99 latency if a call has no upper bound.

The first rule of production resilience: never make an outbound call without a timeout. Retries and backoff build on top of that bound.

Timeouts with AbortSignal.timeout

Modern Node.js (18+) ships AbortSignal.timeout(ms), which produces a signal that aborts automatically after the deadline. The global fetch accepts a signal, so bounding an HTTP call is a one-liner.

  • When the timeout fires, the promise rejects with an AbortError (err.name === 'AbortError').
  • The signal is fire-and-forget: no manual clearTimeout needed.
  • Always distinguish a timeout abort from other network errors so you log and retry correctly.
async function fetchWithTimeout(url, ms) {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError' || err.name === 'TimeoutError') {
      throw new Error('Request timed out after ' + ms + 'ms');
    }
    throw err;
  }
}

// Demo against a hung promise (no real network)
function hang(signal) {
  return new Promise((_, reject) => {
    signal.addEventListener('abort', () => reject(signal.reason));
  });
}

(async () => {
  try {
    await hang(AbortSignal.timeout(50));
  } catch (e) {
    console.log('Aborted:', e.name);
  }
})();

Connect, Read, and Total Timeouts Are Different

"Timeout" is not a single number. A robust client distinguishes several deadlines:

  • Connect timeout: how long to wait for the TCP/TLS handshake.
  • Read / socket-idle timeout: how long to wait between bytes once connected.
  • Total / overall timeout: a hard wall on the entire operation, including retries.

A common bug is setting only a read timeout. A dependency that accepts the connection but never sends the first byte can still stall up to the socket-idle limit. The total budget is the one your caller actually feels, so always cap the whole retry sequence, not just each attempt.

Rule of thumb: perAttemptTimeout x maxAttempts should stay under your overall request budget, or you will blow your own SLA while retrying.

Retry Only Transient, Idempotent Failures

Retrying is dangerous if applied blindly. You must classify the failure before retrying:

  • Retryable (transient): connection reset, DNS hiccup, timeout, HTTP 502/503/504, and 429 (respecting Retry-After).
  • NOT retryable: 400 (bad request), 401/403 (auth), 404, 422. Retrying these just wastes load and never succeeds.
  • Idempotency matters: a GET or PUT is safe to repeat; a non-idempotent POST (charge a card) can double-execute. Use an idempotency key before retrying writes.
function isRetryable(err) {
  // Network-level errors thrown by Node
  const transientCodes = new Set([
    'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'
  ]);
  if (err.code && transientCodes.has(err.code)) return true;
  if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
  // HTTP status carried on the error
  if (err.status && [429, 502, 503, 504].includes(err.status)) return true;
  return false;
}

console.log(isRetryable({ code: 'ECONNRESET' }));   // true
console.log(isRetryable({ status: 503 }));           // true
console.log(isRetryable({ status: 404 }));           // false
console.log(isRetryable(new Error('bad json')));     // false

Fixed Delay Retries Are Not Enough

The naive retry waits a constant delay between attempts:

  • If a dependency is briefly overloaded, all your clients retry at the same fixed interval and hit it again together.
  • This creates a retry storm: the very act of retrying keeps the dependency down.
  • A constant short delay also wastes attempts when the outage lasts seconds, while a constant long delay wastes time on quick blips.

The fix is to grow the wait after each failure (exponential backoff) so pressure on the dependency decreases over time, and to add randomness (jitter) so clients do not synchronize. The next scenes build this up.

Exponential Backoff

Exponential backoff multiplies the delay after each failed attempt, typically by a base of 2:

  • delay = base * 2^attempt, e.g. with base 100ms: 100, 200, 400, 800ms.
  • Always cap the delay with a maxDelay so it does not grow to minutes.
  • The cap turns pure exponential growth into "capped exponential backoff", which is what you almost always want.

The intuition: a quick first retry catches one-off blips, while later attempts back off hard to give a struggling dependency room to recover.

function backoffDelay(attempt, base = 100, maxDelay = 2000) {
  const exp = base * 2 ** attempt;
  return Math.min(exp, maxDelay);
}

for (let attempt = 0; attempt < 6; attempt++) {
  console.log('attempt', attempt, '->', backoffDelay(attempt) + 'ms');
}
// 0->100, 1->200, 2->400, 3->800, 4->1600, 5->2000 (capped)

The Thundering Herd Problem

Pure exponential backoff still has a flaw: if 1,000 clients all failed at the same instant (a shared dependency blipped), they all compute the same delay and retry at the same future moment.

  • The dependency recovers, then gets hit by 1,000 simultaneous retries, and falls over again.
  • This synchronized wave is the thundering herd.
  • Capping the delay does not help, it just synchronizes the herd at the cap.

The cure is jitter: add randomness to each client's delay so the retries spread out across a window instead of landing on the same tick. Jitter is not optional polish, it is the part that actually protects the dependency.

Full Jitter

The AWS-recommended strategy is full jitter: compute the capped exponential ceiling, then pick a uniformly random delay between 0 and that ceiling.

  • cap = min(maxDelay, base * 2^attempt)
  • delay = random(0, cap)

This spreads retries evenly across the whole window, minimizing collisions. Compared to "equal jitter" (half fixed + half random), full jitter generally yields the lowest contention and fewest total calls under load. The small cost is that an individual retry can fire very early, which is fine because the goal is to de-synchronize the herd.

function fullJitterDelay(attempt, base = 100, maxDelay = 2000) {
  const cap = Math.min(maxDelay, base * 2 ** attempt);
  return Math.floor(Math.random() * cap);
}

// Show how 5 "clients" spread out on the same attempt
for (let client = 0; client < 5; client++) {
  console.log('client', client, 'attempt 3 delay:', fullJitterDelay(3) + 'ms');
}
// Each client gets a different value in [0, 800)

Putting It Together: retryWithBackoff

Now combine bounding, classification, capped exponential backoff, and full jitter into one reusable helper. Key design points:

  • Each attempt is independently bounded by a timeout.
  • Only retryable errors trigger another attempt; everything else throws immediately.
  • After the last attempt, rethrow so the caller can fail fast.
  • An overall deadline (not shown here) should still wrap the whole loop in production.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function fullJitter(attempt, base = 100, maxDelay = 2000) {
  const cap = Math.min(maxDelay, base * 2 ** attempt);
  return Math.floor(Math.random() * cap);
}

async function retryWithBackoff(fn, { retries = 4, isRetryable } = {}) {
  let lastErr;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastErr = err;
      if (attempt === retries || !isRetryable(err)) throw err;
      const delay = fullJitter(attempt);
      console.log('attempt', attempt, 'failed, waiting', delay + 'ms');
      await sleep(delay);
    }
  }
  throw lastErr;
}

// Simulate a flaky call that succeeds on the 3rd try
let calls = 0;
retryWithBackoff(
  async () => {
    calls++;
    if (calls < 3) { const e = new Error('flaky'); e.status = 503; throw e; }
    return 'ok after ' + calls + ' calls';
  },
  { retries: 4, isRetryable: (e) => e.status === 503 }
).then(console.log);

Respect Retry-After and Cap the Total Budget

Two production-grade refinements make the difference between a polite client and a load amplifier:

  • Honor Retry-After: on 429 or 503, the server may tell you exactly how long to wait. Always prefer that value over your computed backoff, it is the dependency asking for room.
  • Enforce an overall deadline: track a budget (e.g. 3s). Before sleeping, if now + delay would exceed the deadline, stop retrying and fail fast instead of blowing your SLA.

The combination keeps each retry bounded, the total bounded, and lets the dependency steer its own recovery.

function nextDelay(err, attempt, base = 100, maxDelay = 5000) {
  const header = err.retryAfterSeconds; // parsed from Retry-After
  if (typeof header === 'number') return header * 1000;
  const cap = Math.min(maxDelay, base * 2 ** attempt);
  return Math.floor(Math.random() * cap);
}

const deadline = Date.now() + 3000; // 3s total budget
let attempt = 0;
const err = { status: 429, retryAfterSeconds: 1 };
const delay = nextDelay(err, attempt);

if (Date.now() + delay > deadline) {
  console.log('Budget exhausted, fail fast');
} else {
  console.log('Honoring Retry-After, sleeping', delay + 'ms');
}

Retries Need a Circuit Breaker Above Them

Retries handle transient failures. They are the wrong tool for a sustained outage: if a dependency is hard-down, every request retrying 4 times multiplies your outbound load by 5x at the worst possible moment.

  • Layer a circuit breaker above the retry helper. When failures cross a threshold, the breaker opens and short-circuits calls instantly (fail fast) instead of retrying.
  • After a cool-down it goes half-open, lets a probe through, and closes again on success.
  • Order of layers: breaker -> retry -> timeout. The timeout bounds each attempt, retry handles blips, the breaker stops the bleeding during real outages.

This is the full resilience stack for outbound calls in a Node.js backend.

Quick Check

You operate a Node.js service whose downstream payment provider briefly returns 503 during a deploy. Thousands of your instances all retry. Which single change most directly prevents your retries from re-overloading the provider the instant it recovers?

Recap

You learned how to bound and retry outbound calls without amplifying load:

  • Bound everything: no outbound call without a timeout; distinguish connect, read, and total deadlines via AbortSignal.timeout.
  • Classify before retrying: retry only transient, idempotent failures (timeouts, ECONNRESET, 429/502/503/504); never retry 400/401/404/422.
  • Capped exponential backoff: grow the delay after each failure, but cap it with maxDelay.
  • Full jitter: pick a random delay in [0, cap) to break the thundering herd. This is the part that protects the dependency.
  • Respect Retry-After and a total budget: let the server steer recovery and fail fast before blowing your SLA.
  • Top it with a circuit breaker: breaker -> retry -> timeout handles outages, blips, and slow calls respectively.

常见问题解答

「超时、重试与带抖动的指数退避」课时是免费的吗?

是的 — 「超时、重试与带抖动的指数退避」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Node.js Backend Development Bootcamp 课程的其余内容,请升级到 CoddyKit PRO。 Node.js Backend Development Bootcamp 课程共包含 4 节课。

「超时、重试与带抖动的指数退避」这节课中我会学到什么?

为每次出站调用设置上限,并在不放大依赖服务负载的前提下重试暂时性故障。 你通过在浏览器中直接运行的动手代码来练习 Node.js Backend Development Bootcamp,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Node.js Backend Development Bootcamp 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Node.js Backend Development Bootcamp 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「超时、重试与带抖动的指数退避」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Node.js Backend Development Bootcamp 课中编写并运行代码吗?

能。每节 Node.js Backend Development Bootcamp 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 超时、重试与带抖动的指数退避
  2. 熔断器模式与舱壁隔离
  3. 优雅关闭与在途请求排空
  4. 健康检查、就绪探针与负载丢弃
← 返回 Node.js Backend Development Bootcamp