Blog

Retry, backoff e dead letter queue.

Chamada falhou. Tenta de novo.

É a heurística mais natural que existe e uma das que mais causa incidente grande, porque o retry ingênuo tem uma propriedade perversa: ele piora exatamente a situação que estava tentando resolver.

Por que retry ingênuo derruba sistema

Um serviço fica lento. Suas chamadas começam a dar timeout. Você tenta de novo, imediatamente, três vezes.

Agora você está mandando três vezes mais tráfego pra um serviço que já estava sobrecarregado. Ele fica mais lento. Mais chamadas dão timeout. Mais retries.

Isso tem nome, tempestade de retry, e é o mecanismo por trás de boa parte das quedas em cascata. O serviço que estava se recuperando não consegue, porque a carga triplicou justamente no pior momento.

Tem uma segunda camada: se você tem dez instâncias e todas retentam no mesmo intervalo fixo, elas retentam juntas. Picos sincronizados de carga, em ondas.

As três peças

Backoff exponencial. Cada tentativa espera mais que a anterior. Dá tempo real de recuperação.

Jitter. Aleatoriedade no intervalo, pra dessincronizar as instâncias.

Teto de tentativas. Depois de N, para e manda pra outro lugar.

async function withRetry<T>(fn: () => Promise<T>, opts = { max: 5, baseMs: 200 }) {
  for (let attempt = 0; attempt < opts.max; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (!isRetryable(err) || attempt === opts.max - 1) throw err;

      const exp = opts.baseMs * 2 ** attempt;          // 200, 400, 800, 1600
      const delay = Math.random() * Math.min(exp, 30_000);  // full jitter
      await sleep(delay);
    }
  }
  throw new Error("unreachable");
}

O jitter completo, sorteando entre zero e o valor exponencial, tem melhor comportamento sob contenção do que somar um ruído pequeno. É contraintuitivo e está bem documentado.

O que não se retenta

Essa é a parte que quase todo mundo erra, e ela importa mais que o intervalo.

Retentar erro que nunca vai passar é desperdício, e às vezes é dano.

function isRetryable(err: unknown): boolean {
  if (err instanceof HttpError) {
    if (err.status === 429) return true;              // pediram pra esperar
    if (err.status >= 500) return true;               // problema deles
    if (err.status === 408) return true;              // timeout
    return false;                                     // 4xx: você que está errado
  }
  return isNetworkError(err);                          // ECONNRESET, ETIMEDOUT
}

400, 401, 403, 422 não passam por retentativa. O payload continua inválido, o token continua expirado, a permissão continua faltando. Cinco tentativas produzem cinco erros iguais e cinco vezes mais log.

E 409 merece atenção: pode significar “já existe”, que é sucesso disfarçado num fluxo idempotente.

Retry e idempotência andam juntos

O caso perigoso: você manda a cobrança, o gateway processa, a resposta se perde no caminho. Você vê timeout e retenta.

Sem chave de idempotência, você cobrou duas vezes.

Por isso, sempre que a operação tem efeito colateral, o retry só é seguro se a operação for idempotente. E a chave precisa ser gerada antes da primeira tentativa, não dentro do laço:

// errado: chave nova a cada tentativa, mecanismo anulado
await withRetry(() => gateway.charge({ ...data, idempotencyKey: uuid() }));

// certo
const key = uuid();
await withRetry(() => gateway.charge({ ...data, idempotencyKey: key }));

Escrevi sobre isso em idempotência.

Dead letter queue

Depois do teto de tentativas, a mensagem precisa ir pra algum lugar. Descartar é perder dado em silêncio.

new Worker("payments", handler, {
  connection,
  // BullMQ mantém os falhos; o importante é ter alerta e ferramenta de reprocesso
});

queueEvents.on("failed", async ({ jobId, failedReason }) => {
  await dlq.record({ jobId, reason: failedReason, at: new Date() });
  await alerts.notify(`Job ${jobId} esgotou tentativas: ${failedReason}`);
});

Três coisas que uma DLQ precisa ter pra servir de alguma coisa.

Alerta quando algo entra. DLQ que enche em silêncio é a mesma coisa que descartar, só que ocupando disco.

Contexto suficiente pra investigar: payload completo, motivo de cada tentativa, correlation ID.

E um mecanismo de reprocessamento. Sem ele, a única saída é alguém escrever um script sob pressão, e script sob pressão duplica coisa.

Circuit breaker: quando parar de tentar

Retry resolve falha transitória. Quando o serviço está fora de verdade, continuar tentando só queima recurso seu.

O disjuntor conta falhas numa janela. Passando do limite, abre: as chamadas seguintes falham na hora, sem tocar a rede. Depois de um tempo, deixa passar uma chamada de teste. Se der certo, fecha.

O ganho não é proteger o outro serviço, é proteger o seu: sem disjuntor, todas as suas threads ficam presas esperando timeout de um serviço morto, e a sua aplicação cai junto com a dependência. Com ele, a funcionalidade que dependia daquilo degrada e o resto continua de pé.

O parâmetro esquecido

Timeout.

Retry sem timeout adequado é inútil, porque a chamada fica pendurada e a tentativa seguinte nunca acontece.

E timeout curto demais é pior que não ter: transforma sucesso lento em falha, dispara retry desnecessário e cria a duplicata que a idempotência vai ter que limpar.

A regra prática: meça o percentil 99 da chamada em condição normal e use o dobro ou o triplo. Chutar um número redondo é o que produz o caso do gateway que responde em seis segundos com timeout configurado em cinco.

← Voltar pro blog