Webhook com Redis e retentativa exponencial
Olá meus Unicórnios! 🦄✨
Webhook é aquela promessa simpática: "quando acontecer alguma coisa
aqui, eu aviso aí". Mandar o primeiro é fácil, um fetch
resolve. O problema começa quando o outro lado não responde. 😅
E ele vai não responder. O servidor do cliente reinicia, a internet dele oscila, o banco dele trava por dois minutos. Se o seu código só manda e esquece, o aviso se perdeu para sempre e ninguém fica sabendo. Se ele manda de novo na hora, você vira o problema: fica martelando um servidor que já estava caindo.
A saída é esperar cada vez mais entre uma tentativa e a próxima. Dois segundos, quatro, oito, dezesseis. É isso que se chama de backoff exponencial, e é o assunto deste artigo. Vamos escrever um enviador de webhooks em Node.js que guarda a fila no Redis, tenta de novo com paciência crescente e desiste na hora certa.
🤔 Por que não dá para resolver com setTimeout
Essa é a primeira ideia de todo mundo, e eu já escrevi esse código:
deu erro, chama setTimeout com o dobro do tempo e tenta de
novo. Funciona lindamente nos testes.
Depois você faz um deploy.
O setTimeout mora na memória do processo Node. Quando o
processo morre, ele leva junto todos os webhooks que
estavam esperando para tentar de novo. Não fica erro no log, não fica
registro em lugar nenhum: eles simplesmente deixam de existir. E o pior
momento para um deploy é exatamente quando tem uma pilha de retentativas
pendentes, porque foi um problema que gerou a pilha.
É para isso que o Redis entra. Ele guarda a fila fora do seu processo, então reiniciar o Node não perde nada: ele volta, olha a fila, e encontra tudo lá esperando. 🦄
🧱 As três filas
O desenho inteiro cabe em três chaves do Redis, e entender essa divisão é entender o artigo todo:
| Chave | Tipo | Para que serve |
|---|---|---|
webhooks:prontos |
Lista | Quem tem de sair agora |
webhooks:espera |
Conjunto ordenado | Quem falhou e está esperando a hora de tentar de novo |
webhooks:mortos |
Lista | Quem esgotou as tentativas e ficou para trás |
Repare que a do meio é de um tipo diferente, e não é por capricho. A
lista comum só sabe responder "quem é o próximo". O conjunto ordenado
(o ZSET) guarda um número junto de cada item, e sabe
responder "quem tem número menor que este aqui".
Esse número vai ser a hora da próxima tentativa. Aí perguntar "quem já pode tentar de novo?" vira um comando só: me dê todos com número menor que o relógio de agora. É o truque central deste artigo, e ele evita escrever qualquer agendador. ⏳
📬 O que é um evento aqui
Um webhook, para o nosso código, é um objetinho de quatro campos que a gente converte em texto para guardar no Redis:
{
"id": "evt_1789136700349",
"url": "http://127.0.0.1:4000/teimoso",
"corpo": { "tipo": "pedido.pago", "pedido": 1234, "valor": 99.9 },
"tentativas": 0
}
O corpo é o que o destino vai receber. O
tentativas é o que faz a espera crescer: ele começa em zero
e sobe a cada falha. Como o evento inteiro vira texto e volta a virar
objeto a cada passagem pelo Redis, esse contador viaja junto com ele,
sem precisar de nenhuma outra chave para guardar o estado. 😌
⏱️ A conta do backoff, que é o coração de tudo
Esta é a função mais importante do artigo, e ela tem sete linhas:
const MAXIMO_DE_TENTATIVAS = 5;
const ESPERA_INICIAL_EM_SEGUNDOS = 2;
// Calcula quantos segundos esperar antes da proxima tentativa.
// Dobra a cada tentativa: 2, 4, 8, 16, 32...
function calcularEspera(tentativa) {
let segundos = ESPERA_INICIAL_EM_SEGUNDOS * Math.pow(2, tentativa - 1);
if (segundos > 300) {
segundos = 300;
}
// O "tempero": um pedacinho aleatorio de ate 1 segundo.
// Sem ele, mil webhooks que falharam juntos voltam todos no mesmo
// instante e derrubam o destino de novo.
const tempero = Math.random();
return segundos + tempero;
}
O Math.pow(2, tentativa - 1) é a exponencial: na primeira
falha dá 1, na segunda 2, na terceira 4, na quarta 8. Multiplicado pelos
2 segundos iniciais, vira a sequência 2, 4, 8, 16, 32.
As outras duas linhas são as que doem se faltarem.
O teto de 300 segundos existe porque exponencial não tem noção de limite: na décima tentativa a conta daria mais de mil segundos, e na vigésima daria vinte e quatro dias. 😱 Sem o teto, um webhook com muitas tentativas some no futuro.
E o tempero, esse pedacinho aleatório, é o detalhe que
todo mundo corta achando que é frescura. Pense no que acontece quando o
destino fica fora do ar por um minuto e mil webhooks falham quase juntos:
os mil calculam a mesma espera de 2 segundos, e os mil voltam no mesmo
instante. O servidor que estava só começando a levantar cai de novo, com
os mil pedidos de uma vez. Somando de zero a um segundo em cada um, as
mil tentativas se espalham no tempo em vez de virarem uma manada. 🐎
📮 Enviando e decidindo o que fazer com a resposta
A entrega em si é o pedaço mais curto do programa. Um
fetch com POST e JSON, e nada mais:
async function entregar(url, corpo) {
const resposta = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corpo),
});
return resposta.status;
}
O interessante é o que vem depois. Nem toda falha merece retentativa, e essa distinção é onde mora a diferença entre um enviador bom e um enviador que só gasta banda:
if (status >= 200 && status < 300) {
console.log(" entregue! HTTP " + status);
return;
}
// 4xx e culpa nossa: o corpo esta errado, ou a URL nao existe.
// Tentar de novo vai dar o mesmo erro para sempre, entao desiste logo.
// A excecao e o 429, que quer dizer "devagar", nao "errado".
if (status >= 400 && status < 500 && status !== 429) {
console.log(" HTTP " + status + ": erro nosso, nao adianta repetir");
await redis.rPush(FILA_MORTOS, JSON.stringify(evento));
return;
}
É a regra que mais economiza trabalho do programa. Um
404 significa que aquela URL não existe: ela não vai passar
a existir porque você esperou trinta segundos. Insistir cinco vezes num
404 é gastar cinco pedidos para receber o mesmo erro cinco
vezes, e ainda atrasar os webhooks de verdade que estavam na fila.
O 429 é a exceção, e repare que ele está escrito à mão na
condição. Ele está na faixa dos 4xx mas não quer dizer "seu
pedido está errado": quer dizer "você está mandando rápido demais,
espere". Esse é o caso mais merecedor de backoff que
existe. Tratá-lo como erro definitivo é jogar fora o webhook justamente
quando o destino pediu paciência. 🙏
O que sobra (os 5xx e as falhas de conexão, quando o
fetch nem chega a receber resposta) é problema do outro
lado, pode ser passageiro, e vai para a fila de espera.
🗓️ Agendando a próxima tentativa
Aqui o ZSET mostra para que serve:
async function agendarRetentativa(redis, evento) {
const espera = calcularEspera(evento.tentativas);
const horaDeTentar = Date.now() + espera * 1000;
// O score do conjunto ordenado e a hora em milissegundos.
// E isso que deixa buscar "quem ja venceu" com um comando so.
await redis.zAdd(FILA_ESPERA, {
score: horaDeTentar,
value: JSON.stringify(evento),
});
}
O score é a hora em que este evento deve voltar, em
milissegundos. Nada acontece nesse momento: o evento fica parado no
Redis, e o programa segue trabalhando nos outros.
Quem o traz de volta é a outra metade do truque:
// Pega da fila de espera todos os que ja venceram e devolve para os prontos.
async function promoverVencidos(redis) {
const agora = Date.now();
const vencidos = await redis.zRangeByScore(FILA_ESPERA, 0, agora);
for (const texto of vencidos) {
// Remove ANTES de empurrar para os prontos. Ao contrario,
// um segundo enviador rodando junto pega o mesmo evento e
// o destino recebe a entrega duas vezes.
const removeu = await redis.zRem(FILA_ESPERA, texto);
if (removeu === 1) {
await redis.rPush(FILA_PRONTOS, texto);
}
}
}
O zRangeByScore de zero até agora devolve exatamente os
eventos cuja hora já chegou. Um comando, sem agendador, sem tabela de
timers, sem nada.
Agora a ordem das duas linhas de dentro do laço, que é a armadilha que
eu mais vejo passar batido. Parece indiferente empurrar antes e remover
depois. Não é. Se você roda dois enviadores (e uma hora
você vai rodar, porque um só não dá conta), os dois chamam
zRangeByScore quase ao mesmo tempo e os dois recebem o mesmo
evento vencido. Empurrando primeiro, os dois empurram, e o cliente recebe
o mesmo webhook duas vezes. 😳
Removendo primeiro, o zRem decide a disputa sozinho: ele
devolve 1 para quem de fato removeu e 0 para
quem chegou depois e não encontrou mais nada. Só quem recebeu
1 empurra. É por isso que o if está ali.
🔁 O laço principal
Junta tudo:
while (true) {
await promoverVencidos(redis);
// Espera ate 1 segundo por um evento novo, sem queimar CPU
// num laco vazio. Passado o segundo, volta para promover os
// vencidos: e o que faz a retentativa acontecer na hora certa.
const item = await redis.blPop(FILA_PRONTOS, 1);
if (item === null) {
continue;
}
try {
await processarUm(redis, item.element);
} catch (erro) {
console.log("erro ao processar o evento: " + erro.message);
}
}
O blPop é um pop que espera:
se a fila estiver vazia, ele fica parado no Redis até um segundo,
devolvendo null se não veio nada. É o que evita o laço
infinito girando a mil por hora e fritando um núcleo do processador à
toa.
O 1 do segundo argumento é o tempo máximo de espera. Ele
importa mais do que parece: é ele que define a pontualidade da
retentativa. Se você colocar 30 ali, o programa pode ficar
trinta segundos parado no blPop enquanto um evento agendado
para daqui a dois segundos já venceu e ninguém foi buscar. Um segundo dá
o compromisso certo entre não gastar CPU e não atrasar ninguém.
E repare que o try/catch abraça o
processamento inteiro. Um JSON mal formado que entrou na fila por engano
derrubaria o enviador no JSON.parse, e aí nenhum
webhook sairia mais. O bloco existe para que um evento estragado não leve
o programa junto. 🛟
🧪 Um destino de teste com três rotas
Para ver o backoff acontecer você precisa de alguém que falhe quando
você mandar. Vinte linhas de http puro resolvem, sem
instalar nada:
// Destino de teste. Tres rotas, para ver os tres caminhos:
// /ok sempre aceita
// /quebra sempre devolve 500
// /teimoso falha as duas primeiras vezes e aceita na terceira
const http = require("http");
let vezes = 0;
const servidor = http.createServer(function (req, res) {
let corpo = "";
req.on("data", function (parte) { corpo = corpo + parte; });
req.on("end", function () {
if (req.url === "/ok") {
console.log("[destino] recebi em /ok: " + corpo);
res.writeHead(200); res.end("ok"); return;
}
if (req.url === "/quebra") {
console.log("[destino] /quebra: devolvendo 500");
res.writeHead(500); res.end("erro"); return;
}
if (req.url === "/teimoso") {
vezes = vezes + 1;
if (vezes < 3) {
console.log("[destino] /teimoso: falha numero " + vezes);
res.writeHead(503); res.end("indisponivel"); return;
}
console.log("[destino] /teimoso: agora vai, na vez " + vezes);
res.writeHead(200); res.end("ok"); return;
}
res.writeHead(404); res.end("nao existe");
});
});
servidor.listen(4000, function () {
console.log("destino falso ouvindo em http://127.0.0.1:4000");
});
A rota /teimoso é a estrela: ela recusa as duas primeiras
entregas e aceita a terceira, que é exatamente o caso que o backoff
existe para resolver. Um destino que estava fora do ar e voltou.
▶️ Rodando
Você precisa de um Redis de pé. Se você tem Docker, é uma linha:
docker run -d --name redis-webhook -p 6379:6379 redis:7
A única dependência do projeto é o cliente do Redis. O
fetch já vem pronto no Node 18 para cima, então não precisa
de biblioteca nenhuma para a parte HTTP:
npm install redis
Aí são três terminais. No primeiro, o destino de teste:
node destino-falso.js
No segundo, o enviador, que fica de pé ouvindo a fila:
node enviador.js
E no terceiro você joga os webhooks na fila:
node enfileirar.js http://127.0.0.1:4000/ok
node enfileirar.js http://127.0.0.1:4000/teimoso
👀 A espera dobrando, na saída do terminal
Este é o momento em que o artigo inteiro vira visível. O primeiro
evento vai para /ok e passa de primeira; o segundo vai para
/teimoso e precisa de três tentativas:
enviador de pe, ouvindo o Redis em redis://127.0.0.1:6379
-> tentativa 1 do evento evt_1789137061247 para http://127.0.0.1:4000/ok
entregue! HTTP 200
-> tentativa 1 do evento evt_1789137063723 para http://127.0.0.1:4000/teimoso
HTTP 503: o destino falhou
agendado para daqui a 2.9s (tentativa 2 de 5)
-> tentativa 2 do evento evt_1789137063723 para http://127.0.0.1:4000/teimoso
HTTP 503: o destino falhou
agendado para daqui a 4.1s (tentativa 3 de 5)
-> tentativa 3 do evento evt_1789137063723 para http://127.0.0.1:4000/teimoso
entregue! HTTP 200
Olhe os dois números da espera: 2,9s e 4,1s. A conta base era 2 e 4; a diferença é o tempero aleatório entrando (quase 1 segundo no primeiro, apenas um décimo no segundo). É o jitter funcionando à vista. 🎲
E do outro lado, o destino confirmando que recebeu três vezes e só aceitou na última:
destino falso ouvindo em http://127.0.0.1:4000
[destino] recebi em /ok: {"tipo":"pedido.pago","pedido":1234,"valor":99.9}
[destino] /teimoso: falha numero 1
[destino] /teimoso: falha numero 2
[destino] /teimoso: agora vai, na vez 3
💀 Quando não dá certo nunca
O caminho feliz é bonito, mas quem prova que o desenho está certo é o
caminho da falha. Vamos jogar três webhooks condenados de uma vez: um
para /quebra (que sempre devolve 500), um para uma URL que
não existe, e um para uma porta onde não tem ninguém ouvindo.
node enfileirar.js http://127.0.0.1:4000/quebra
node enfileirar.js http://127.0.0.1:4000/nao-existe
node enfileirar.js http://127.0.0.1:4999/fora-do-ar
E é isto que sai:
-> tentativa 1 do evento evt_1789137115810 para http://127.0.0.1:4000/quebra
HTTP 500: o destino falhou
agendado para daqui a 2.2s (tentativa 2 de 5)
-> tentativa 1 do evento evt_1789137117315 para http://127.0.0.1:4000/nao-existe
HTTP 404: erro nosso, nao adianta repetir
-> tentativa 2 do evento evt_1789137115810 para http://127.0.0.1:4000/quebra
HTTP 500: o destino falhou
agendado para daqui a 4.7s (tentativa 3 de 5)
-> tentativa 1 do evento evt_1789137118832 para http://127.0.0.1:4999/fora-do-ar
falhou na conexao: fetch failed
agendado para daqui a 2.4s (tentativa 2 de 5)
-> tentativa 2 do evento evt_1789137118832 para http://127.0.0.1:4999/fora-do-ar
falhou na conexao: fetch failed
agendado para daqui a 4.1s (tentativa 3 de 5)
-> tentativa 3 do evento evt_1789137115810 para http://127.0.0.1:4000/quebra
HTTP 500: o destino falhou
agendado para daqui a 8.3s (tentativa 4 de 5)
-> tentativa 3 do evento evt_1789137118832 para http://127.0.0.1:4999/fora-do-ar
falhou na conexao: fetch failed
agendado para daqui a 8.7s (tentativa 4 de 5)
-> tentativa 4 do evento evt_1789137115810 para http://127.0.0.1:4000/quebra
HTTP 500: o destino falhou
agendado para daqui a 16.2s (tentativa 5 de 5)
-> tentativa 4 do evento evt_1789137118832 para http://127.0.0.1:4999/fora-do-ar
falhou na conexao: fetch failed
agendado para daqui a 16.1s (tentativa 5 de 5)
-> tentativa 5 do evento evt_1789137115810 para http://127.0.0.1:4000/quebra
HTTP 500: o destino falhou
desistiu depois de 5 tentativas
-> tentativa 5 do evento evt_1789137118832 para http://127.0.0.1:4999/fora-do-ar
falhou na conexao: fetch failed
desistiu depois de 5 tentativas
Tem três coisas para reparar aqui, e cada uma confirma um pedaço do desenho. 🔍
A curva apareceu inteira: 2,2 depois 4,7, depois 8,3, depois 16,2. Cada uma é praticamente o dobro da anterior, com o tempero aleatório por cima. É a exponencial que a gente escreveu, medida no relógio.
O 404 saiu na primeira linha e nunca mais
voltou. Enquanto os outros dois penaram por mais de trinta
segundos cada, ele foi descartado na hora. Sem aquela regra dos
4xx, ele teria ocupado cinco tentativas para chegar
exatamente na mesma conclusão.
A porta fechada se comporta igualzinha ao 500. O
fetch failed acontece antes de existir qualquer resposta
HTTP, então não tem status para classificar. Como é um problema do outro
lado e pode passar, ele merece o mesmo tratamento: por isso o
status começa em 0 e o catch só
registra o erro em vez de desistir.
Os três terminaram na lista dos mortos, e é ali que eles ficam esperando por você:
$ redis-cli lrange webhooks:mortos 0 -1
{"id":"evt_1789137117315","url":"http://127.0.0.1:4000/nao-existe","corpo":{"tipo":"pedido.pago","pedido":1234,"valor":99.9},"tentativas":1}
{"id":"evt_1789137115810","url":"http://127.0.0.1:4000/quebra","corpo":{"tipo":"pedido.pago","pedido":1234,"valor":99.9},"tentativas":5}
{"id":"evt_1789137118832","url":"http://127.0.0.1:4999/fora-do-ar","corpo":{"tipo":"pedido.pago","pedido":1234,"valor":99.9},"tentativas":5}
Repare no tentativas de cada um: o do 404
morreu com 1, os outros dois com 5. Só
de olhar essa lista você já sabe quem foi erro seu de configuração e quem
foi destino que ficou fora do ar mesmo.
E é por isso que eles não são apagados. Um webhook que falhou cinco vezes ainda é uma informação: alguém deveria ter sido avisado de alguma coisa e não foi. Com o corpo original guardado, dá para reenviar à mão depois que o problema do cliente for resolvido. Apagar seria fingir que o evento nunca existiu. 🙈
📄 O enviador completo
Este é o arquivo inteiro, de cima a baixo. Nenhuma classe, nenhuma
herança, nenhuma camada: funções soltas e um main() no
fim.
// Enviador de webhooks com Redis e retentativa exponencial.
// Rode com: node enviador.js
const { createClient } = require("redis");
const ENDERECO_REDIS = process.env.REDIS_URL || "redis://127.0.0.1:6379";
// Fila de quem esta pronto para sair agora.
const FILA_PRONTOS = "webhooks:prontos";
// Fila de quem esta esperando a hora de tentar de novo.
const FILA_ESPERA = "webhooks:espera";
// Onde ficam os que desistiram de vez.
const FILA_MORTOS = "webhooks:mortos";
const MAXIMO_DE_TENTATIVAS = 5;
const ESPERA_INICIAL_EM_SEGUNDOS = 2;
// Calcula quantos segundos esperar antes da proxima tentativa.
// Dobra a cada tentativa: 2, 4, 8, 16, 32...
function calcularEspera(tentativa) {
let segundos = ESPERA_INICIAL_EM_SEGUNDOS * Math.pow(2, tentativa - 1);
if (segundos > 300) {
segundos = 300;
}
// O "tempero": um pedacinho aleatorio de ate 1 segundo.
// Sem ele, mil webhooks que falharam juntos voltam todos no mesmo
// instante e derrubam o destino de novo.
const tempero = Math.random();
return segundos + tempero;
}
async function entregar(url, corpo) {
const resposta = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corpo),
});
return resposta.status;
}
// Move para a fila de espera com a hora exata da proxima tentativa.
async function agendarRetentativa(redis, evento) {
const espera = calcularEspera(evento.tentativas);
const horaDeTentar = Date.now() + espera * 1000;
// O score do conjunto ordenado e a hora em milissegundos.
// E isso que deixa buscar "quem ja venceu" com um comando so.
await redis.zAdd(FILA_ESPERA, {
score: horaDeTentar,
value: JSON.stringify(evento),
});
console.log(
" agendado para daqui a " + espera.toFixed(1) + "s" +
" (tentativa " + (evento.tentativas + 1) + " de " + MAXIMO_DE_TENTATIVAS + ")"
);
}
async function processarUm(redis, textoDoEvento) {
const evento = JSON.parse(textoDoEvento);
evento.tentativas = evento.tentativas + 1;
console.log(
"-> tentativa " + evento.tentativas + " do evento " + evento.id +
" para " + evento.url
);
let status = 0;
try {
status = await entregar(evento.url, evento.corpo);
} catch (erro) {
console.log(" falhou na conexao: " + erro.message);
}
if (status >= 200 && status < 300) {
console.log(" entregue! HTTP " + status);
return;
}
// 4xx e culpa nossa: o corpo esta errado, ou a URL nao existe.
// Tentar de novo vai dar o mesmo erro para sempre, entao desiste logo.
// A excecao e o 429, que quer dizer "devagar", nao "errado".
if (status >= 400 && status < 500 && status !== 429) {
console.log(" HTTP " + status + ": erro nosso, nao adianta repetir");
await redis.rPush(FILA_MORTOS, JSON.stringify(evento));
return;
}
if (status !== 0) {
console.log(" HTTP " + status + ": o destino falhou");
}
if (evento.tentativas >= MAXIMO_DE_TENTATIVAS) {
console.log(" desistiu depois de " + evento.tentativas + " tentativas");
await redis.rPush(FILA_MORTOS, JSON.stringify(evento));
return;
}
await agendarRetentativa(redis, evento);
}
// Pega da fila de espera todos os que ja venceram e devolve para os prontos.
async function promoverVencidos(redis) {
const agora = Date.now();
const vencidos = await redis.zRangeByScore(FILA_ESPERA, 0, agora);
for (const texto of vencidos) {
// Remove ANTES de empurrar para os prontos. Ao contrario,
// um segundo enviador rodando junto pega o mesmo evento e
// o destino recebe a entrega duas vezes.
const removeu = await redis.zRem(FILA_ESPERA, texto);
if (removeu === 1) {
await redis.rPush(FILA_PRONTOS, texto);
}
}
}
async function main() {
const redis = createClient({ url: ENDERECO_REDIS });
redis.on("error", function (erro) {
console.log("erro no Redis: " + erro.message);
});
await redis.connect();
console.log("enviador de pe, ouvindo o Redis em " + ENDERECO_REDIS);
while (true) {
await promoverVencidos(redis);
// Espera ate 1 segundo por um evento novo, sem queimar CPU
// num laco vazio. Passado o segundo, volta para promover os
// vencidos: e o que faz a retentativa acontecer na hora certa.
const item = await redis.blPop(FILA_PRONTOS, 1);
if (item === null) {
continue;
}
try {
await processarUm(redis, item.element);
} catch (erro) {
console.log("erro ao processar o evento: " + erro.message);
}
}
}
main();
E o enfileirar.js, que é só quem coloca coisa na fila:
// Coloca um webhook na fila. Rode com:
// node enfileirar.js http://127.0.0.1:4000/ok
const { createClient } = require("redis");
const ENDERECO_REDIS = process.env.REDIS_URL || "redis://127.0.0.1:6379";
const FILA_PRONTOS = "webhooks:prontos";
async function main() {
const url = process.argv[2];
if (!url) {
console.log("uso: node enfileirar.js <url-de-destino>");
return;
}
const redis = createClient({ url: ENDERECO_REDIS });
await redis.connect();
const evento = {
id: "evt_" + Date.now(),
url: url,
corpo: { tipo: "pedido.pago", pedido: 1234, valor: 99.9 },
tentativas: 0,
};
await redis.rPush(FILA_PRONTOS, JSON.stringify(evento));
console.log("evento " + evento.id + " na fila para " + url);
await redis.quit();
}
main();
Repare que no seu sistema de verdade não é o enfileirar.js
que roda: é o seu código, no momento em que o pedido foi pago ou o
cadastro foi criado, chamando aquele mesmo rPush. Um
comando, e a responsabilidade de entregar acaba ali. Quem persegue o
destino é o enviador, num processo separado. 🦄
⚠️ O detalhe que quebra em silêncio
Tem um lugar deste código onde eu me enrolei, e ele não dá erro nenhum
quando está errado: o evento.tentativas sendo incrementado
no começo do processarUm, e não no fim.
const evento = JSON.parse(textoDoEvento);
evento.tentativas = evento.tentativas + 1; // ANTES de tentar entregar
Se você incrementar só quando falha, parece a mesma coisa, e a conta até dá certo enquanto tudo falha. Mas o número deixa de significar "quantas vezes eu bati nessa porta" e passa a significar "quantas vezes deu erro", que não é o mesmo. O evento que teve sucesso na terceira tentativa fica gravado com 2, e a linha do log mente sobre o que aconteceu.
Incrementando antes de tentar, o número conta o que realmente
importa: quantas entregas foram disparadas. É por isso que o
404 lá em cima apareceu na lista dos mortos com
"tentativas":1, e não com zero. Ele foi tentado uma vez. 😌
🔧 O que ajustar quando isso for para produção
Os números que eu escolhi são bons para ver o backoff acontecer num artigo, não para um sistema de verdade. Duas mudanças que fazem diferença:
A espera inicial de 2 segundos é curta demais. Ela existe aqui para o terminal mostrar a curva inteira em meio minuto. Num sistema real, um destino que caiu raramente volta em dois segundos: 30 segundos de espera inicial, dobrando até o teto, dá uma janela de horas em vez de segundos, que é o tempo que uma pessoa leva para perceber e arrumar um servidor.
Cinco tentativas dão pouco mais de um minuto de paciência. Com a espera inicial maior e o teto de 300 segundos, oito ou dez tentativas cobrem quase um dia inteiro sem nunca bater no destino mais de uma vez a cada cinco minutos.
E o teto continua sendo obrigatório nos dois casos. Ele é o que transforma a curva exponencial numa curva que sobe e depois estabiliza, em vez de uma que some no infinito. ⏳
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
O que é backoff exponencial?
Por que preciso de Redis para isso? Não dá com <code>setTimeout</code>?
setTimeout vive na memória do Node: um deploy, uma queda ou um Ctrl+C e todos os webhooks que estavam esperando para tentar de novo somem sem deixar rastro. No Redis eles ficam gravados, e quando o processo volta ele encontra tudo lá esperando. É essa a diferença que importa.Por que somar um valor aleatório na espera?
Devo tentar de novo quando a resposta é 404 ou 400?
4xx dizem que o problema está no pedido que você mandou: a URL não existe, o corpo está errado, o token é inválido. Tentar de novo daqui a uma hora vai dar exatamente o mesmo erro. A exceção é o 429, que significa "devagar aí", não "errado": esse merece retentativa. Os 5xx e as falhas de conexão também merecem, porque o problema é do outro lado e pode passar.O que acontece quando acabam as tentativas?
Por que remover do <code>ZSET</code> antes de empurrar para a fila de prontos?
ZSET: o ZREM devolve 1 para quem removeu e 0 para quem chegou depois.Leia também
API Mágica: notificações Web Push no navegador com PHP
Web Push com a API Mágica em PHP puro: o navegador se inscreve, o seu site envia a notificação e a API conta o clique. Sem Composer e sem biblioteca.
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.