0Pricing
Node.js Backend Development Bootcamp · Aula

Timeouts, novas tentativas e recuo exponencial com jitter

Limite todas as chamadas de saída e repita tentativas após falhas transitórias sem amplificar a carga sobre as dependências.

Timeouts, novas tentativas e recuo exponencial com jitter é uma aula grátis de Node.js Backend Development Bootcamp no CoddyKit. Esta é a aula 1 de 4. Você pode ler a aula completa abaixo gratuitamente — depois pratica ao vivo no navegador com um editor de código integrado e um tutor de IA 24/7. Faz parte do caminho de aprendizado de Node.js Backend Development Bootcamp, e seu progresso é sincronizado entre a web e o app CoddyKit. O curso de Node.js Backend Development Bootcamp inclui 4 aulas no total.

Partes desta aula ainda não foram traduzidas e aparecem em inglês.

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.

Perguntas Frequentes

A aula “Timeouts, novas tentativas e recuo exponencial com jitter” é grátis?

Sim — o texto completo de “Timeouts, novas tentativas e recuo exponencial com jitter” é grátis para ler aqui na web. Para praticá-la interativamente (um editor de código integrado e um tutor de IA 24/7) e desbloquear o restante do curso de Node.js Backend Development Bootcamp, atualize para CoddyKit PRO. O curso de Node.js Backend Development Bootcamp inclui 4 aulas no total.

O que vou aprender em “Timeouts, novas tentativas e recuo exponencial com jitter”?

Limite todas as chamadas de saída e repita tentativas após falhas transitórias sem amplificar a carga sobre as dependências. Você pratica Node.js Backend Development Bootcamp com código prático que executa diretamente no navegador, e um tutor de IA 24/7 responde suas dúvidas enquanto trabalha na aula.

Preciso ter experiência prévia para começar Node.js Backend Development Bootcamp?

Nenhuma experiência prévia é necessária. Node.js Backend Development Bootcamp no CoddyKit é estruturado para alunos iniciantes até avançados, então você pode começar aqui ou desde o início e aprender no seu ritmo. Esta é a aula 1 de 4.

Quanto tempo leva a aula “Timeouts, novas tentativas e recuo exponencial com jitter”?

A maioria das aulas CoddyKit leva cerca de 5–10 minutos. Cada uma é compacta e interativa, então você faz progresso constante e retoma exatamente de onde parou entre web e app.

Posso escrever e executar código nesta aula de Node.js Backend Development Bootcamp?

Sim. Cada aula de Node.js Backend Development Bootcamp inclui um editor de código integrado, então você escreve e executa código real direto no navegador e recebe feedback de IA instantaneamente — nenhuma configuração local necessária.

Todas as aulas deste curso

  1. Timeouts, novas tentativas e recuo exponencial com jitter
  2. Padrão Circuit Breaker e isolamento por anteparos
  3. Desligamento controlado e drenagem de requisições em andamento
  4. Verificações de integridade, sondas de prontidão e redução de carga
← Voltar para Node.js Backend Development Bootcamp