Pular para o conteúdo
Node.js

Webhook com Redis e retentativa exponencial

Ilustração colorida de um unicórnio carteiro entregando uma carta selada diante de um castelo, ao lado de uma ampulheta cujos grãos ficam cada vez maiores ao cair, com uma coruja conferindo pergaminhos e prateleiras de frascos enfileirados

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:

ChaveTipoPara 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?
É esperar cada vez mais entre uma tentativa e a seguinte, em vez de repetir num intervalo fixo. A espera dobra a cada falha: 2 segundos, depois 4, depois 8, 16, 32. A ideia é dar tempo de o destino se recuperar. Se ele caiu porque está sobrecarregado, repetir de segundo em segundo só piora o que já está ruim.
Por que preciso de Redis para isso? Não dá com <code>setTimeout</code>?
Dá, até o seu processo reiniciar. O 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?
Porque sem ele acontece o que se chama de manada. Imagine que o destino ficou fora do ar por um minuto e mil webhooks falharam quase juntos. Todos calculam a mesma espera de 2 segundos, e todos voltam no mesmo instante, derrubando de novo o servidor que estava só começando a levantar. Somando de zero a um segundo aleatório em cada um, as mil tentativas se espalham no tempo.
Devo tentar de novo quando a resposta é 404 ou 400?
Não. Os erros da faixa 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?
O evento vai para uma terceira lista, a dos mortos, que em inglês costuma se chamar dead letter queue. Ele não é apagado: fica lá com o corpo original e a contagem de tentativas, para você olhar depois e decidir se reenvia à mão ou se aquele destino está abandonado mesmo. Apagar um webhook que falhou é perder a informação de que ele existiu.
Por que remover do <code>ZSET</code> antes de empurrar para a fila de prontos?
Por causa da entrega duplicada. Se você empurra primeiro e remove depois, dois enviadores rodando ao mesmo tempo podem ler o mesmo evento vencido e mandar os dois para a fila de prontos, e o destino recebe o webhook duas vezes. Removendo antes, só ganha o evento quem conseguiu de fato tirá-lo do ZSET: o ZREM devolve 1 para quem removeu e 0 para quem chegou depois.

Leia também