Blog

Cenário: a cobrança que saiu duas vezes.

O que vem a seguir é um cenário construído para estudo. Não é o incidente de nenhuma empresa específica: é a versão destilada de um padrão que reaparece em praticamente todo sistema de pagamento, com nomes trocados e a mesma anatomia.

Vale como exercício porque quase ninguém erra a parte difícil. Erra-se a parte que parecia resolvida.

Terça, 14h07

Sobe uma versão nova do serviço de assinatura. Mudança pequena: o webhook do gateway passa a gravar um registro de auditoria antes de confirmar o pagamento. Duas linhas. Revisada por duas pessoas. Testes verdes.

Terça, 14h11

Primeiro cliente reclama de cobrança dobrada. Em vinte minutos são nove.

O que estava acontecendo

O gateway envia um webhook quando o pagamento é aprovado. Se não receber 200 em cinco segundos, reenvia. Até seis vezes, com espaçamento crescente.

O handler fazia isto:

@Post("webhook/payment")
async handle(@Body() event: PaymentEvent) {
  await this.audit.record(event);              // linha nova
  const subscription = await this.subs.findById(event.subscriptionId);
  await this.subs.activate(subscription, event);
  await this.ledger.credit(event);
  return { ok: true };
}

A gravação de auditoria acrescentou por volta de 4,5 segundos ao caminho, porque escrevia num banco secundário com pool de conexão pequeno e saturado no horário de pico. O tempo total passou de cinco segundos.

O gateway concluiu que a entrega falhou e reenviou. O segundo webhook entrou enquanto o primeiro ainda processava. Os dois creditaram.

A parte nova do código não tinha nenhum bug. Ela só empurrou o tempo de resposta por cima de um limite que ninguém sabia que existia.

Por que os testes não pegaram

Porque o teste chamava o handler uma vez, com o banco vazio, sem concorrência e sem latência. A duplicação exige três condições simultâneas que nenhum teste unitário reproduz por acidente: a mesma mensagem duas vezes, sobrepostas no tempo, com o gateway real decidindo o reenvio.

Esse é o formato do problema. Não é falta de teste. É teste que valida a lógica e não valida a premissa.

As três correções, em ordem de valor

Idempotência no banco. O evento do gateway traz um identificador único. Ele vira constraint:

alter table payment_event
  add constraint payment_event_provider_id_unique unique (provider_event_id);

O segundo webhook tenta inserir, viola a constraint, e o handler responde 200 sem reprocessar. Não é validação na aplicação, que tem corrida entre a verificação e a escrita. É o banco arbitrando, que é o único lugar onde isso é atômico. Escrevi o desenho completo em idempotência.

Responder rápido, processar depois. Webhook deve validar assinatura, gravar o evento e devolver 200. O processamento vai pra fila. O tempo de resposta deixa de depender de quanto o trabalho demora, e a auditoria pode levar trinta segundos sem consequência.

@Post("webhook/payment")
async handle(@Body() event: PaymentEvent, @Headers("signature") sig: string) {
  this.verifySignature(event, sig);
  const { created } = await this.events.recordOnce(event);  // insere ou ignora
  if (created) await this.queue.add("process-payment", { id: event.id });
  return { ok: true };  // sempre 200, sempre rápido
}

Verificação de balanceamento. Uma consulta que roda de hora em hora somando as pernas de cada transação do ledger e alertando quando não fecha em zero. Não previne nada. Reduz o tempo entre o erro e a descoberta, que é o que determina o tamanho do estrago.

O que ficou de aprendizado

O tempo de resposta de um webhook é um contrato, não uma métrica. Passar dele muda o comportamento do sistema do outro lado, e o outro lado não é seu.

Toda operação que custa dinheiro precisa de uma chave única no banco. Não porque o código de hoje duplica, mas porque o código de daqui a seis meses vai ter um caminho que ninguém previu.

E o mais desconfortável: a mudança que quebrou tudo era irrelevante. Alguém adicionou logging. Latência é um efeito colateral que não aparece em revisão de código, porque a revisão olha lógica, e o problema não estava na lógica.

Um bom teste de fogo pra qualquer handler de webhook: chame ele duas vezes com o mesmo payload, em paralelo, e veja o que acontece. Se você não sabe a resposta de cor, vale descobrir antes que o gateway descubra por você.

← Voltar pro blog