TL;DR
- O backoff exponencial ingênuo (
delay = base * 2^attempt) cria thundering herds, porque todos os clientes tentam de novo ao mesmo tempo - Adicione jitter (aleatoriedade) para espalhar as novas tentativas (retries). O equal jitter (
base/2 + random(base/2)) é o ponto ideal - Limite o atraso máximo, limite a duração total das novas tentativas e separe 4xx (não tentar de novo) de 5xx (tentar de novo)
- Use um circuit breaker no nível do destino depois de cerca de 20 falhas consecutivas. Novas tentativas não consertam um endpoint quebrado
- Replay barato vence nova tentativa agressiva: se o replay (um novo envio de um evento armazenado, feito por você) é um clique, você só precisa de novas tentativas para lidar com falhas temporárias
A maior parte do código de nova tentativa que vejo por aí se parece com isto:
for (let i = 0; i < maxRetries; i++) {
try {
return await deliver(event);
} catch {
await sleep(1000 * Math.pow(2, i)); // 1s, 2s, 4s, 8s...
}
}
Isso é backoff exponencial, e é melhor do que tentar de novo em intervalos constantes. Também está errado de um jeito que só aparece em produção, quando centenas de webhooks falham ao mesmo tempo e todos tentam de novo no mesmo instante, derrubando outra vez seu servidor que ainda se recuperava.
A correção é o jitter. Veja por quê e como fazer isso direito.
O thundering herd
Imagine que seu endpoint cai em T+0. Nos 60 segundos seguintes, chegam 500 webhooks da Stripe, e todos falham. Toda fila de novas tentativas agora tem 500 novas tentativas da primeira rodada programadas para T+5min (ou qualquer que seja seu atraso base), 500 da segunda rodada para T+10min, e assim por diante.
Seu endpoint volta ao ar em T+4min. Parabéns: agora você recebe 500 novas tentativas simultâneas batendo em T+5min, levando de novo além do ponto de saturação seu servidor que ainda está se recuperando. Esse é o thundering herd.
O backoff determinístico tem esse problema por construção. Todo cliente ou evento que falhou ao mesmo tempo tenta de novo ao mesmo tempo.
Jitter
O jitter adiciona aleatoriedade ao momento das novas tentativas, para que os eventos sejam tentados de novo de forma espalhada, e não num pico. Três estratégias comuns:
Full jitter
const base = 1000 * Math.pow(2, attempt); // 1s, 2s, 4s, 8s
const sleep = Math.random() * base; // 0 to base
Cada nova tentativa espera qualquer tempo entre 0 e o intervalo de backoff inteiro. Espalhamento máximo, mas algumas novas tentativas disparam quase na hora, o que pode parecer ineficiente.
Equal jitter
const base = 1000 * Math.pow(2, attempt);
const sleep = base / 2 + Math.random() * (base / 2);
Cada nova tentativa espera entre a metade e o backoff inteiro. Mínimo limitado, e ainda um bom espalhamento.
Decorrelated jitter
let sleep = baseDelay;
// on each retry:
sleep = Math.min(maxDelay, baseDelay + Math.random() * (sleep * 3 - baseDelay));
O atraso de cada nova tentativa é um múltiplo aleatório do anterior. Bom quando você quer que o backoff cresça sem uma fórmula fixa.
Para webhooks, o equal jitter é o ponto ideal. Ele mantém as novas tentativas mais ou menos exponenciais e, ao mesmo tempo, desarma a manada.
Como são os cronogramas de novas tentativas prontos para produção
Além do jitter, é isto que equipes experientes fazem.
Limite o atraso máximo. Não deixe a tentativa 10 esperar 17 minutos. Defina um teto, digamos 60 s, e, depois de chegar a ele, fique nele até desistir.
Limite a duração total das novas tentativas, não só o número de tentativas. "Tentar de novo por 24 horas" é um SLA mais claro do que "tentar de novo 8 vezes", porque o segundo depende de uma conta de atrasos de que ninguém se lembra.
Separe erros temporários de erros definitivos. Um 4xx do destino, principalmente 401, 403 ou 404, não vai dar certo numa nova tentativa. O servidor está dizendo que a requisição está errada. Só tente de novo em caso de 5xx, 429, timeouts e erros de conexão.
Mostre o estado das novas tentativas. Seus usuários precisam saber que um evento está na tentativa 3 de 5, com a próxima nova tentativa num horário específico. Se isso for invisível, depurar é doloroso.
Use um circuit breaker no nível do destino. Se um destino falhou 20 vezes seguidas, pare de tentar de novo por um tempo. Há algo errado que novas tentativas não vão consertar, e você só está queimando computação.
Como o AnyHook lida com isso
A política de novas tentativas do AnyHook é ajustada por plano e usa, por baixo, a infraestrutura gerenciada de novas tentativas do QStash. São 3 tentativas de entrega no Free, 5 no Pro e 10 no Scale, numa escada exponencial fixa: 5 minutos, 30 minutos, 3 horas, 18 horas e depois 24 horas para cada tentativa seguinte. O AnyHook não adiciona jitter a esses atrasos. Os tetos de timeout são 60 s, 120 s e 5 minutos, respectivamente. Depois de 20 falhas consecutivas, o circuit breaker pausa a entrega para aquele destino (10 minutos e, nas aberturas seguintes, 30 minutos, 2 horas e 24 horas) e manda um e-mail para você, então não há tempestades de novas tentativas. Um 4xx que não seja 408 nem 429 não é tentado de novo, e o alerta de falha explica por quê, já que novas tentativas não ajudam nesse caso e você precisa corrigir o formato da requisição.
Esgotar as novas tentativas não faz o evento se perder. Ele fica armazenado no seu log com status=failed, e você pode fazer o replay manualmente pelo painel ou pela API a qualquer momento dentro da janela de replay do seu plano (3 dias no Free, 30 no Pro, 90 no Scale).
O macete
A percepção que muda o jeito como você pensa em novas tentativas: você não precisa de novas tentativas agressivas se tem replay barato.
Se o replay é um clique, tentar de novo 3 vezes ao longo de uma hora basta. Você pega automaticamente a maioria das falhas temporárias e, na rara queda longa, um humano faz o replay do lote com falha quando o servidor estiver saudável.
Compare com sistemas que só têm novas tentativas, em que você estende a janela de novas tentativas para 72 horas por via das dúvidas, o que significa 72 horas de entregas desperdiçadas para um endpoint que você já sabe que está fora do ar.
Replay barato é estritamente melhor do que nova tentativa agressiva, e é em torno disso que o AnyHook foi construído.
Conclusão
Trate as novas tentativas como um caminho rápido para falhas temporárias, não como substituto de persistência e replay. Os sistemas que continuam de pé nas semanas ruins são aqueles em que uma entrega com falha é sempre recuperável, porque o evento foi registrado antes de ser entregue.
O cronograma real de cada remetente está na nossa referência de provedores de webhook. As novas tentativas também têm um teto rígido que ninguém controla: continue falhando e a Shopify pode excluir a inscrição, enquanto a Stripe desativa o endpoint. E as novas tentativas são justamente o motivo pelo qual duplicatas e entrega fora de ordem são garantidas, e não raras: Você recebeu o mesmo webhook duas vezes.