Node.js Backend Development Bootcamp · 강의

타임아웃, 재시도와 지터를 적용한 지수 백오프

모든 외부 호출에 제한 시간을 설정하고, 의존 서비스의 부하를 증폭시키지 않으면서 일시적인 실패를 재시도하는 방법을 배웁니다.

레슨 1/413개 단계

타임아웃, 재시도와 지터를 적용한 지수 백오프은(는) CoddyKit의 무료 Node.js Backend Development Bootcamp 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 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 튜터와 함께 JavaScript을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
22
레슨
92

자주 묻는 질문

“타임아웃, 재시도와 지터를 적용한 지수 백오프” 강의는 무료인가요?

네 — “타임아웃, 재시도와 지터를 적용한 지수 백오프” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Node.js Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

“타임아웃, 재시도와 지터를 적용한 지수 백오프”에서 뭘 배우나요?

모든 외부 호출에 제한 시간을 설정하고, 의존 서비스의 부하를 증폭시키지 않으면서 일시적인 실패를 재시도하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Node.js Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

Node.js Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 Node.js Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.

“타임아웃, 재시도와 지터를 적용한 지수 백오프” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 Node.js Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 Node.js Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 타임아웃, 재시도와 지터를 적용한 지수 백오프
  2. 서킷 브레이커 패턴과 벌크헤드 격리
  3. 우아한 종료와 진행 중인 요청 처리
  4. 상태 확인, 준비 상태 프로브와 부하 차단
← Node.js Backend Development Bootcamp(으)로 돌아가기