Blog

Webhook de saída que ninguém confirma: entregar evento a cliente lento sem acumular fila infinita

A plataforma de pedidos enviava webhooks para mil e oitocentas integrações de clientes: ERPs, sistemas de expedição, planilhas conectadas e automações de marketing. Numa terça-feira, o ERP de um único varejista passou a responder em vinte e oito segundos, por causa de uma migração do lado dele. O timeout de envio era de trinta segundos, então nenhuma chamada falhava: todas demoravam. Em quarenta minutos, os dezesseis workers de entrega estavam presos nesse endpoint, a fila passou de dois milhões de entregas e os outros mil setecentos e noventa e nove clientes começaram a receber eventos de pedido com três horas de atraso. Os que deram timeout voltavam imediatamente para a fila e disputavam os mesmos workers. Quando o varejista terminou a migração, recebeu novecentos mil eventos em poucos minutos e caiu de novo. Nenhum cliente fez nada de errado do ponto de vista dele, e ninguém do lado da plataforma tinha como confirmar se um evento tinha chegado. Este artigo explica como um destino lento prende a entrega de todos, qual contrato de confirmação um webhook de saída precisa ter, como montar uma fila por destino com limite de concorrência, como retentar com recuo, jitter e prazo de validade, como pausar, fundir e descartar com critério em vez de acumular para sempre, e o que medir para saber que um cliente está ficando para trás antes que ele abra um chamado.

2026-09-29 / Arquitetura / 18 min

01

Como um único cliente lento prende a entrega de todos

A conta que explica o incidente é a lei de Little: a concorrência necessária para acompanhar um fluxo é a taxa de chegada multiplicada pelo tempo de atendimento. O varejista recebia doze eventos por segundo. Com respostas de vinte e oito segundos, acompanhar esse único destino exigiria trezentas e trinta e seis conexões simultâneas. O pool tinha dezesseis workers para todos os destinos. Como a fila era única e em ordem de chegada, e a parte dela que mais crescia era justamente a desse cliente, cada worker que terminava uma entrega pegava a próxima da fila e, cada vez com mais frequência, ela era do destino lento.

Workers de entrega (16 no total) durante o incidente

  14:00  [A][B][C][D][E][F][G][H][I][J][K][L][M][N][O][P]   varios destinos
  14:15  [X][X][X][X][X][B][C][D][E][F][G][H][I][J][K][L]   X responde em 28 s
  14:30  [X][X][X][X][X][X][X][X][X][X][X][B][C][D][E][F]   fila de X so cresce
  14:40  [X][X][X][X][X][X][X][X][X][X][X][X][X][X][X][X]   todos presos em X

  Taxa de eventos de X ........ 12 por segundo
  Tempo por resposta de X ..... 28 segundos
  Concorrencia necessaria .... 12 x 28 = 336 conexoes simultaneas
  Concorrencia disponivel ..... 16 workers para 1.800 destinos

Nada no desenho estava errado isoladamente. O defeito estava na soma de decisões razoáveis que, juntas, entregam a capacidade de todos a quem responde pior. A tabela resume as decisões que costumam aparecer juntas e o que cada uma faz quando um destino fica lento.

DecisãoPor que parecia razoávelO que faz com um destino lento
Timeout de 30 segundosEvita falsos negativos em clientes que demoram um poucoCada entrega lenta segura um worker por 30 segundos em vez de liberar em 5
Fila única em ordem de chegadaSimples de implementar e de raciocinarO destino com mais eventos acumulados passa a ocupar todos os workers
Retentativa imediataO erro pode ter sido momentâneoDobra a carga sobre quem já não está dando conta
Sem prazo de validadeNenhum evento pode ser perdidoA fila cresce sem limite e eventos de três dias atrás disputam com os de agora
Sem teto por destinoTodos os clientes são tratados igualmenteUm cliente sozinho define a latência de todos os outros
Reenvio de tudo quando o destino voltaEntregar o que estava pendente o quanto antesO cliente que acabou de se recuperar recebe uma rajada e cai de novo

02

O contrato de confirmação: o que conta como entregue e quanto esperar

O título do problema é literal: ninguém confirma porque o contrato nunca disse o que é confirmar. A regra que resolve a maior parte dos casos é publicar que uma entrega só conta quando o destino responde com status 2xx em até cinco segundos, e que o corpo da resposta é ignorado. Isso obriga o cliente a fazer o que ele deveria fazer de qualquer forma: gravar o evento, responder na hora e processar depois, na própria fila dele. Um endpoint que chama três APIs e grava em quatro tabelas antes de responder não é um receptor de webhook, é um processamento síncrono disfarçado, e ele vai estourar o prazo exatamente nos dias de mais movimento.

Resposta do destinoAção da plataformaConta como falha do destino?
2xx em até 5 sMarca como entregue e zera as falhas seguidas do destinoNão
Timeout, conexão recusada, erro de DNS ou TLSRetenta com recuoSim
429 ou 503 com Retry-AfterRetenta depois do tempo pedido, respeitando o tetoSim
408, 425, 429 e 5xx sem Retry-AfterRetenta com recuoSim
3xxNão segue o redirecionamento; retenta e avisa que a URL mudouSim
400, 401, 403, 404Retenta com recuo, porque costuma ser configuração que o cliente corrigeSim
410 GoneDesativa o destino e para de enviarEncerra o destino
413 Payload Too LargeMarca a entrega como morta, porque repetir não muda o tamanhoSim

Três detalhes evitam surpresas. Não seguir redirecionamentos impede que um endpoint mal configurado, ou comprometido, faça a plataforma enviar eventos assinados para outro endereço. Não ler o corpo da resposta impede que um destino que devolve uma página de erro de dez megabytes consuma memória do worker. E o timeout precisa cobrir a chamada inteira, conexão, TLS e resposta, e não apenas o tempo de conexão, que é o padrão de várias bibliotecas HTTP e a razão de muitos workers ficarem pendurados por minutos em servidores que aceitam a conexão e nunca respondem.

03

Uma fila por destino, não uma fila para todos

A correção estrutural é tratar cada endpoint como uma fila própria com um limite de concorrência, e fazer os workers passarem pelos destinos de forma intercalada. Com um limite de quatro entregas simultâneas por destino, o varejista lento ocupa no máximo quatro conexões, a fila dele cresce sozinha e os demais clientes continuam recebendo em segundos. Para até algumas centenas de entregas por segundo, o PostgreSQL resolve isso bem com duas tabelas, e a entrega passa a ser gravada na mesma transação que muda o pedido, sem risco de o evento se perder entre o banco e a fila.

CREATE TABLE webhook_endpoints (
  id                bigserial PRIMARY KEY,
  cliente_id        bigint NOT NULL,
  url               text NOT NULL,
  segredo           text NOT NULL,
  status            text NOT NULL DEFAULT 'ativo'
                    CHECK (status IN ('ativo', 'desativado')),
  max_concorrencia  int NOT NULL DEFAULT 4,
  falhas_seguidas   int NOT NULL DEFAULT 0,
  primeira_falha_em timestamptz,
  pausado_ate       timestamptz
);

CREATE TABLE webhook_entregas (
  id           bigserial PRIMARY KEY,
  endpoint_id  bigint NOT NULL REFERENCES webhook_endpoints (id),
  evento_id    uuid NOT NULL,
  tipo         text NOT NULL,
  payload      jsonb NOT NULL,
  chave_fusao  text,
  status       text NOT NULL DEFAULT 'pendente'
               CHECK (status IN ('pendente', 'enviando', 'entregue', 'morta')),
  tentativas   int NOT NULL DEFAULT 0,
  proxima_em   timestamptz NOT NULL DEFAULT now(),
  reservado_em timestamptz,
  criado_em    timestamptz NOT NULL DEFAULT now(),
  entregue_em  timestamptz,
  ultimo_erro  text
);

-- A fila de cada destino, na ordem em que deve ser tentada
CREATE INDEX webhook_entregas_fila
  ON webhook_entregas (endpoint_id, proxima_em, id)
  WHERE status = 'pendente';

-- Entregas em voo, para contar a concorrencia de cada destino
CREATE INDEX webhook_entregas_em_voo
  ON webhook_entregas (endpoint_id)
  WHERE status = 'enviando';

-- No maximo uma entrega pendente por entidade e destino (fusao)
CREATE UNIQUE INDEX webhook_entregas_fusao
  ON webhook_entregas (endpoint_id, chave_fusao)
  WHERE status = 'pendente' AND chave_fusao IS NOT NULL;

A reserva escolhe, para cada destino ativo e fora de pausa, no máximo as vagas que ele ainda tem livres, usando o índice parcial da fila para ler só as primeiras linhas de cada um. Depois ordena pela posição dentro de cada destino, o que dá a primeira entrega de cada cliente antes da segunda de qualquer um. Um cliente com dois milhões de pendentes e outro com três disputam em igualdade: cada um recebe a mesma fatia enquanto tiver entregas prontas.

-- reserva-sql.js exporta este texto como RESERVA_SQL; $1 = vagas do processo
WITH em_voo AS (
  SELECT endpoint_id, count(*) AS n
  FROM webhook_entregas
  WHERE status = 'enviando'
  GROUP BY endpoint_id
),
candidatas AS (
  SELECT c.id, c.pos
  FROM webhook_endpoints ep
  LEFT JOIN em_voo v ON v.endpoint_id = ep.id
  CROSS JOIN LATERAL (
    SELECT e.id, row_number() OVER (ORDER BY e.proxima_em, e.id) AS pos
    FROM webhook_entregas e
    WHERE e.endpoint_id = ep.id
      AND e.status = 'pendente'
      AND e.proxima_em <= now()
    ORDER BY e.proxima_em, e.id
    LIMIT greatest(ep.max_concorrencia - coalesce(v.n, 0), 0)
  ) c
  WHERE ep.status = 'ativo'
    AND (ep.pausado_ate IS NULL OR ep.pausado_ate <= now())
),
escolhidas AS (
  -- Ordenar por posicao intercala os destinos: a primeira de cada um,
  -- depois a segunda de cada um, e assim por diante
  SELECT id FROM candidatas ORDER BY pos, id LIMIT $1
)
UPDATE webhook_entregas w
SET status = 'enviando',
    tentativas = w.tentativas + 1,
    reservado_em = now()
FROM escolhidas s, webhook_endpoints ep
WHERE w.id = s.id
  AND ep.id = w.endpoint_id
RETURNING w.id, w.endpoint_id, w.evento_id, w.tipo, w.payload,
          w.tentativas, w.criado_em, ep.url, ep.segredo;

A contagem de entregas em voo e a reserva precisam acontecer sem que outro processo reserve as mesmas vagas no meio do caminho. FOR UPDATE SKIP LOCKED impede duas reservas da mesma linha, mas não impede que dois workers contem quatro vagas livres ao mesmo tempo e reservem oito entregas do mesmo destino. Por isso a reserva roda dentro de uma transação curta que toma um advisory lock: ela leva poucos milissegundos, então serializar esse trecho custa pouco, e o limite por destino passa a ser exato. Se a plataforma crescer a ponto de a reserva virar gargalo, o passo seguinte é particionar os destinos entre grupos de workers, cada grupo com o próprio lock.

04

Retentativa com recuo, jitter e prazo de validade

Cada falha reagenda a entrega com recuo exponencial: trinta segundos na primeira, dobrando a cada tentativa, até um teto de seis horas. O jitter usa metade do intervalo fixa e metade aleatória, para que entregas que falharam juntas não voltem juntas, e o valor de Retry-After é respeitado quando o destino o envia. Há dois limites de desistência, vinte tentativas ou setenta e duas horas de idade, e o que vier primeiro manda a entrega para o estado morta, de onde ela só sai por reenvio explícito.

TentativaEspera antes da próximaObservação
115 a 30 segundosAbsorve reinícios e falhas momentâneas
31 a 2 minutosCobre um deploy do lado do cliente
68 a 16 minutosO destino provavelmente já está em pausa
102 h 08 a 4 h 16Falha que já é um incidente do cliente
11 a 203 a 6 horasTeto; a vigésima acontece entre 31 e 63 horas depois do evento

O worker abaixo junta as peças: reserva entregas até o limite do processo, envia com timeout de cinco segundos para a chamada inteira, assina o corpo com HMAC incluindo o timestamp, classifica a resposta e registra o resultado. Ele usa fetch nativo do Node 18 ou superior e o driver pg. A cada falha, além de reagendar a entrega, ele incrementa as falhas seguidas do destino, o que alimenta a pausa descrita na próxima seção.

import crypto from 'node:crypto';
import pg from 'pg';
import { RESERVA_SQL } from './reserva-sql.js';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

const TIMEOUT_MS = 5_000;
const MAX_EM_VOO = 64; // entregas simultaneas deste processo
const MAX_TENTATIVAS = 20;
const IDADE_MAXIMA_MS = 72 * 3_600_000;
const BASE_MS = 30_000;
const TETO_MS = 6 * 3_600_000;
const FALHAS_PARA_PAUSAR = 20;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Recuo exponencial com jitter: metade fixa, metade aleatoria. Nunca antes
// do que o cliente pediu em Retry-After, limitado ao teto.
export function proximoAtrasoMs(tentativas, retryAfterMs = 0) {
  const exp = Math.min(TETO_MS, BASE_MS * 2 ** (tentativas - 1));
  const atraso = exp / 2 + Math.random() * (exp / 2);
  return Math.max(Math.round(atraso), Math.min(retryAfterMs, TETO_MS));
}

export function parseRetryAfter(valor) {
  if (!valor) return 0;
  const segundos = Number(valor);
  if (Number.isFinite(segundos)) return Math.max(0, segundos * 1000);
  const data = Date.parse(valor);
  return Number.isNaN(data) ? 0 : Math.max(0, data - Date.now());
}

async function enviar(e) {
  const corpo = JSON.stringify({ id: e.evento_id, tipo: e.tipo, criado_em: e.criado_em, dados: e.payload });
  const ts = String(Math.floor(Date.now() / 1000));
  const assinatura = crypto.createHmac('sha256', e.segredo).update(ts + '.' + corpo).digest('hex');
  try {
    const res = await fetch(e.url, {
      method: 'POST',
      redirect: 'manual',
      signal: AbortSignal.timeout(TIMEOUT_MS),
      headers: {
        'content-type': 'application/json',
        'webhook-id': e.evento_id,
        'webhook-timestamp': ts,
        'webhook-signature': 'v1=' + assinatura,
      },
      body: corpo,
    });
    await res.body?.cancel(); // o corpo da resposta nao interessa
    return { status: res.status, retryAfterMs: parseRetryAfter(res.headers.get('retry-after')) };
  } catch (err) {
    const erro = err.name === 'TimeoutError' ? 'timeout' : String(err.cause?.code || err.message);
    return { status: 0, erro };
  }
}

async function concluir(e, r) {
  if (r.status >= 200 && r.status < 300) {
    await pool.query(
      "UPDATE webhook_entregas SET status = 'entregue', entregue_em = now(), ultimo_erro = NULL WHERE id = $1",
      [e.id],
    );
    await pool.query(
      'UPDATE webhook_endpoints SET falhas_seguidas = 0, primeira_falha_em = NULL, pausado_ate = NULL WHERE id = $1',
      [e.endpoint_id],
    );
    return;
  }

  const erro = r.erro || 'HTTP ' + r.status;

  if (r.status === 410) {
    // O cliente disse explicitamente que o endpoint nao existe mais
    await pool.query("UPDATE webhook_endpoints SET status = 'desativado' WHERE id = $1", [e.endpoint_id]);
    await pool.query("UPDATE webhook_entregas SET status = 'morta', ultimo_erro = $2 WHERE id = $1", [e.id, erro]);
    return;
  }

  const idadeMs = Date.now() - new Date(e.criado_em).getTime();
  const desistir = r.status === 413 || e.tentativas >= MAX_TENTATIVAS || idadeMs >= IDADE_MAXIMA_MS;

  if (desistir) {
    await pool.query("UPDATE webhook_entregas SET status = 'morta', ultimo_erro = $2 WHERE id = $1", [e.id, erro]);
  } else {
    await pool.query(
      "UPDATE webhook_entregas SET status = 'pendente', proxima_em = now() + $2::float8 * interval '1 millisecond', " +
        'ultimo_erro = $3 WHERE id = $1',
      [e.id, proximoAtrasoMs(e.tentativas, r.retryAfterMs), erro],
    );
  }

  // Falhas seguidas pausam o destino inteiro; cada rodada de sondagem que
  // falha estende a pausa, ate uma hora
  await pool.query(
    'UPDATE webhook_endpoints SET falhas_seguidas = falhas_seguidas + 1, ' +
      'primeira_falha_em = coalesce(primeira_falha_em, now()), ' +
      'pausado_ate = CASE WHEN falhas_seguidas + 1 >= $2 ' +
      "THEN now() + least(interval '1 hour', (falhas_seguidas + 2 - $2) * interval '1 minute') " +
      'ELSE pausado_ate END WHERE id = $1',
    [e.endpoint_id, FALHAS_PARA_PAUSAR],
  );
}

async function reservar(limite) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    // Serializa a reserva entre processos: sem isso, dois workers contam as
    // mesmas vagas e passam do limite de concorrencia do destino
    await client.query('SELECT pg_advisory_xact_lock(4201)');
    // Devolve a fila o que ficou preso por um worker que morreu no meio do envio
    await client.query(
      "UPDATE webhook_entregas SET status = 'pendente', proxima_em = now() " +
        "WHERE status = 'enviando' AND reservado_em < now() - interval '1 minute'",
    );
    const { rows } = await client.query(RESERVA_SQL, [limite]);
    await client.query('COMMIT');
    return rows;
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {});
    throw err;
  } finally {
    client.release();
  }
}

export async function rodar() {
  const emVoo = new Set();
  for (;;) {
    const vagas = MAX_EM_VOO - emVoo.size;
    let entregas = [];
    if (vagas > 0) {
      try {
        entregas = await reservar(vagas);
      } catch (err) {
        console.error('falha ao reservar entregas', err);
      }
    }
    for (const e of entregas) {
      const p = enviar(e)
        .then((r) => concluir(e, r))
        .catch((err) => console.error('falha ao registrar entrega', e.id, err))
        .finally(() => emVoo.delete(p));
      emVoo.add(p);
    }
    if (entregas.length === 0) await Promise.race([...emVoo, sleep(500)]);
  }
}
  • O timeout de cinco segundos é aplicado com AbortSignal.timeout, que cobre conexão, TLS, envio e espera da resposta.
  • Uma entrega presa em enviando por mais de um minuto, porque o processo morreu no meio do envio, volta para a fila na próxima reserva; o cliente pode receber o evento duas vezes, e por isso o identificador do evento vai no cabeçalho.
  • O teto de seis horas vale também para Retry-After: um destino que pede para esperar trinta dias não bloqueia a entrega além do teto.
  • A retentativa nunca é imediata, nem na primeira falha, porque a primeira falha de um destino sobrecarregado é exatamente o momento em que repetir mais piora tudo.

05

Teto por destino: pausar, fundir e descartar com critério

Limitar a concorrência protege os outros clientes, mas não impede que a fila do destino lento cresça sem fim. Três mecanismos resolvem isso. O primeiro é a pausa: depois de vinte falhas seguidas, o destino para de receber tentativas por alguns minutos, e cada rodada de sondagem que falha estende a pausa até uma hora. Quando ela termina, a reserva libera no máximo as quatro vagas do destino, que funcionam como sondagem: se derem certo, as falhas zeram e a entrega volta ao ritmo normal; se falharem, a pausa recomeça. Isso é um disjuntor, com a diferença de que o estado dele mora no banco e vale para todos os workers.

O segundo é a fusão. Boa parte dos eventos descreve o estado atual de uma entidade, como pedido atualizado ou estoque alterado. Se um destino está parado com cinquenta atualizações pendentes do mesmo pedido, entregar as cinquenta não tem valor: só a última importa. Com uma chave de fusão por entidade, uma entrega pendente é substituída pela versão mais nova em vez de criar outra linha, e a fila de um destino parado passa a ter o tamanho do número de entidades que mudaram, e não do número de mudanças. Eventos que representam fatos, como pagamento aprovado ou nota emitida, não podem ser fundidos e ficam sem chave. O terceiro é a desativação: um destino que falha há cinco dias seguidos é desativado, o responsável técnico do cliente é avisado e as entregas pendentes ficam guardadas até ele decidir reenviar ou descartar.

-- Enfileira um evento de estado para todos os destinos ativos do cliente.
-- Se o destino ja tem entrega pendente da mesma entidade, troca o payload
-- pelo mais novo em vez de criar outra linha.
INSERT INTO webhook_entregas (endpoint_id, evento_id, tipo, payload, chave_fusao)
SELECT ep.id, $1, $2, $3, $4
FROM webhook_endpoints ep
WHERE ep.cliente_id = $5
  AND ep.status = 'ativo'
ON CONFLICT (endpoint_id, chave_fusao)
  WHERE status = 'pendente' AND chave_fusao IS NOT NULL
DO UPDATE SET evento_id = EXCLUDED.evento_id,
              tipo      = EXCLUDED.tipo,
              payload   = EXCLUDED.payload;

-- Job diario: destino que falha ha cinco dias seguidos e desativado,
-- e a aplicacao avisa o responsavel tecnico do cliente
UPDATE webhook_endpoints
SET status = 'desativado'
WHERE status = 'ativo'
  AND primeira_falha_em < now() - interval '5 days'
RETURNING id, cliente_id, url;
MecanismoQuando usarCusto para o cliente
Pausa com sondagemSempre; é o que evita martelar um destino caídoNenhum; os eventos esperam
Fusão por entidadeEventos que carregam o estado completo da entidadePerde os estados intermediários, que em geral ninguém usa
Eventos finosPayloads grandes ou sensíveis; o evento leva só o id e o cliente busca o estado atual na APIUma chamada extra por evento
Prazo de validade e entrega mortaSempre; nenhum evento fica tentando para semprePrecisa reconciliar pelo log ou pedir reenvio
Desativação após dias de falhaDestinos abandonados, que representam boa parte da fila paradaReativar o endpoint no painel

A volta de um destino também precisa de cuidado. Sem limite, o varejista que terminou a migração receberia novecentos mil eventos em minutos. Com o limite de concorrência, a vazão para ele fica em torno de quatro entregas divididas pelo tempo de resposta: com respostas de duzentos milissegundos, vinte por segundo. Isso esvazia a fila em horas, sem derrubar o cliente de novo, e o valor de max_concorrencia pode ser ajustado por destino para clientes que aguentam mais.

06

O que o cliente precisa saber e o que você precisa medir

Metade da confiabilidade de um webhook de saída está na documentação que o cliente lê. Sem ela, cada integração inventa as próprias suposições, e as suposições erradas viram chamados. O contrato publicado precisa dizer, no mínimo:

  • Que a entrega é pelo menos uma vez: o mesmo evento pode chegar duas vezes, e o cabeçalho webhook-id é a chave para descartar repetições.
  • Que a ordem não é garantida: cada payload leva a versão ou a data de atualização da entidade, e o receptor ignora o que for mais antigo do que já tem.
  • Como validar a assinatura, incluindo a tolerância de cinco minutos para o timestamp, que impede a repetição de um evento capturado.
  • O prazo de cinco segundos, a tabela de respostas e o que acontece com o destino depois de dias falhando.
  • Como reconciliar: uma API que lista eventos por intervalo de tempo, guardados por trinta dias, para que o cliente recupere o que perdeu sem depender de reenvio.

Do lado da plataforma, a métrica que teria antecipado o incidente não é a taxa de erro, que ficou perto de zero enquanto tudo demorava. É a idade da entrega pendente mais antiga, por destino. Ela sobe quando o destino está lento, quando está falhando e quando está pausado, e ela é a medida direta do que o cliente sente. A consulta abaixo mostra os destinos mais atrasados, com fila, ocupação e estado de pausa.

-- Por destino: fila, ocupacao e idade da entrega pendente mais antiga
SELECT ep.id,
       ep.url,
       count(*) FILTER (WHERE e.status = 'pendente') AS pendentes,
       count(*) FILTER (WHERE e.status = 'enviando') AS em_voo,
       ep.max_concorrencia,
       extract(epoch FROM now() - min(e.criado_em) FILTER (WHERE e.status = 'pendente'))
         AS idade_mais_antiga_s,
       ep.falhas_seguidas,
       ep.pausado_ate
FROM webhook_endpoints ep
LEFT JOIN webhook_entregas e
  ON e.endpoint_id = ep.id AND e.status IN ('pendente', 'enviando')
WHERE ep.status = 'ativo'
GROUP BY ep.id
ORDER BY idade_mais_antiga_s DESC NULLS LAST
LIMIT 20;
MétricaPor destino ou globalAlerta sugerido
Idade da entrega pendente mais antigaPor destinoAcima de 15 minutos em cliente ativo; avisar o cliente, não só o time
Idade da entrega pendente mais antiga entre destinos saudáveisGlobalAcima de 1 minuto; indica falta de capacidade na plataforma
Taxa de sucesso na primeira tentativaPor destinoQueda abaixo de 95% em uma hora
p95 do tempo de respostaPor destinoAcima de 2 segundos, antes de chegar ao timeout
Destinos em pausa e desativadosGlobalCrescimento fora do padrão da semana
Entregas mortas por diaPor destino e globalQualquer valor acima de zero em cliente com contrato de integração

Separar a idade global dos destinos saudáveis da idade por destino é o que permite distinguir um cliente com problema de uma plataforma sem capacidade. Se só um destino está atrasado, o problema é dele, e o painel de entregas com o último erro e um botão de reenviar resolve a maior parte das conversas. Se todos estão atrasando juntos, o problema é seu.

FAQ

Perguntas frequentes

Por que usar o PostgreSQL como fila em vez de SQS, RabbitMQ ou Kafka?

Porque o requisito central é limitar a concorrência por destino, e poucos brokers fazem isso de forma nativa com milhares de destinos. Criar uma fila por endpoint vira um problema operacional quando os endpoints são criados e removidos pelos clientes, e uma fila única com consumidores que limitam por destino reencontra o bloqueio no início da fila. Com o banco, a entrega é gravada na mesma transação da mudança de negócio, a fila por destino é um índice parcial e o painel de entregas é uma consulta. Até algumas centenas de entregas por segundo isso funciona bem. Acima disso, o caminho costuma ser um broker particionado pelo identificador do destino, com consumidores que mantêm um limite por chave, e o banco continua como fonte da verdade do estado de cada entrega.

Devo garantir a ordem de entrega dos eventos?

Na maior parte dos casos, não. Garantir ordem por destino exige limitar a concorrência a um e parar toda a fila do destino atrás de uma única entrega que falha, o que transforma um evento problemático em atraso para todos os outros daquele cliente. É mais robusto levar em cada payload a versão ou a data de atualização da entidade, para que o receptor descarte o que for mais antigo do que já tem, ou usar eventos finos, em que o cliente busca o estado atual na API e a ordem deixa de importar. Quando a ordem é realmente necessária, como em um razão contábil, ela deve ser por entidade, não por destino, e a documentação precisa dizer isso.

O que fazer com as entregas que morreram?

Elas não são lixo, são uma lista de trabalho. O cliente precisa ver no painel quais eventos não chegaram e por quê, com o último erro de cada um, e poder reenviá-los por intervalo de tempo depois de corrigir o endpoint. Em paralelo, a API de listagem de eventos permite que ele reconcilie sem depender da plataforma. Do lado interno, vale acompanhar as entregas mortas por cliente: um volume constante costuma indicar um destino abandonado que deveria ser desativado, enquanto um pico repentino costuma indicar uma mudança do lado do cliente que precisa de contato direto.

Webhook de saída é uma fila por cliente, com prazo e com teto

Um destino lento não derruba a plataforma por falhar, e sim por demorar: ele segura workers, a fila cresce e todos os outros clientes passam a esperar por ele. O que resolve é um contrato claro de confirmação em cinco segundos, uma fila por destino com limite de concorrência e reserva intercalada, retentativa com recuo, jitter e prazo de validade, pausa com sondagem, fusão de eventos de estado e desativação de destinos abandonados. A métrica que avisa antes do cliente é a idade da entrega pendente mais antiga, por destino. Posso revisar como a sua plataforma envia webhooks, implementar a fila por destino com esses limites e montar o painel de entregas e as métricas que mostram quando um cliente está ficando para trás.