Idempotência em APIs de pagamento: o que quebra quando o cliente reenvia a mesma requisição

Escrito por

em

Um POST /charges estoura o timeout do cliente em 30 segundos. O gateway, do outro lado, processou a cobrança normalmente aos 34 segundos. O cliente não recebeu resposta, aplica a política de retry e envia a mesma requisição. Resultado: duas cobranças no cartão do usuário final, um chamado de estorno que custa mais caro que o ticket e uma reconciliação manual no fim do mês.

Idempotência é a resposta padrão, e quase toda equipe implementa alguma versão dela. O problema é que a implementação intuitiva — consultar se o registro já existe antes de inserir — falha exatamente na situação que motivou a solução: duas requisições concorrentes, ainda em voo, disputando o mesmo recurso. O que segue destrincha cada decisão de projeto e o critério de escolha.

O que a retentativa quebra, em ordem de gravidade

Nem toda repetição tem o mesmo custo. Vale separar três níveis antes de decidir quanta complexidade o sistema comporta.

O primeiro nível é o efeito colateral duplicado e irreversível: cobrar duas vezes, emitir dois boletos, disparar duas transferências. Aqui o dano é financeiro e visível para o usuário final. É o caso que justifica praticamente qualquer investimento de engenharia.

O segundo é o efeito duplicado reversível: dois e-mails de confirmação, dois webhooks para o parceiro. O dano é absorvível, mas corrói integrações — parceiros que recebem eventos duplicados passam a construir deduplicação do lado deles, o que empurra o problema para fora.

O terceiro é o estado inconsistente parcial, o mais insidioso. A primeira requisição criou a cobrança, chamou o adquirente, gravou a transação e morreu antes de atualizar o saldo. A segunda entra, encontra a cobrança criada, assume que está tudo feito e retorna sucesso. O saldo nunca é atualizado e ninguém percebe até o fechamento. Idempotência mal implementada produz esse cenário com mais frequência do que a ausência total de tratamento, porque cria a ilusão de que o caso já foi coberto.

A conclusão prática: idempotência não é uma verificação de duplicidade, é uma garantia de que o resultado observável de N execuções da mesma requisição é igual ao de uma execução. Isso inclui devolver a mesma resposta, com o mesmo corpo e o mesmo status, não apenas evitar o segundo insert.

A chave de idempotência: quem gera, o que ela cobre, quanto tempo dura

A chave é um identificador que o cliente gera e envia em um cabeçalho, normalmente Idempotency-Key, e que o servidor usa para reconhecer que duas requisições são a mesma tentativa lógica.

Gerar a chave no servidor não funciona: se o servidor gerasse, a segunda requisição receberia uma chave diferente e seria tratada como nova. A chave precisa nascer no ponto em que a intenção do usuário se forma — a tela de checkout, o job que dispara a cobrança — e permanecer estável por todas as retentativas daquela intenção. Um UUID v4 gerado no início do fluxo resolve. Derivar a chave de um hash do corpo da requisição é tentador e problemático: dois pagamentos legítimos e idênticos do mesmo valor, no mesmo minuto, colidiriam.

Sobre o escopo, a chave deve ser única por cliente da API e por endpoint, não globalmente. Escopo global cria acoplamento desnecessário entre integradores e transforma colisão acidental em incidente de segurança — um cliente conseguiria ler a resposta de outro se adivinhasse a chave. A tabela de idempotência deve ter chave composta por identificador do consumidor, rota e chave enviada.

O tempo de retenção é onde a maioria dos projetos erra por excesso ou por falta. Reter por 24 horas cobre a janela realista de retry de qualquer cliente bem-comportado e a maior parte dos incidentes de rede. Reter para sempre transforma a tabela em um passivo que cresce indefinidamente e passa a dominar o custo de escrita. Reter por 5 minutos deixa de fora o cliente que ficou offline e reprocessou a fila meia hora depois. A faixa de 24 a 72 horas é o consenso na maior parte dos gateways de pagamento; abaixo disso é preciso ter certeza sobre o comportamento de retry de todos os consumidores.

Uma regra que economiza incidente: a chave precisa cobrir também o corpo da requisição. Se a mesma chave chegar com um payload diferente — valor alterado, cartão diferente —, o correto é responder 422 em vez de devolver a resposta antiga. Isso captura bug de cliente que reutiliza chave por engano, que é uma classe de erro bem mais comum que a retentativa legítima.

Guarde o hash do corpo junto com a chave e compare na entrada: sha256(body) != registro.body_hash retorna 422 com mensagem explícita. Sem essa comparação, um cliente com bug consegue receber a resposta de uma cobrança de R$ 10 ao tentar cobrar R$ 1.000, e o erro só aparece na conciliação.

Onde guardar o registro: banco relacional, cache distribuído ou o próprio recurso

Existem três lugares plausíveis para materializar a chave, com trade-offs bem diferentes.

Estratégia Garantia Custo Quando usar
Tabela dedicada no mesmo banco transacional Forte: a chave e o efeito colateral entram na mesma transação Uma escrita a mais por requisição; tabela precisa de expurgo Efeito colateral irreversível e banco relacional já no caminho crítico
Redis com SET NX e TTL Fraca sob falha: a chave pode gravar e o efeito não, ou vice-versa Baixo, latência de milissegundos Deduplicação de efeitos reversíveis — e-mail, webhook, notificação
Constraint única no próprio recurso Forte, sem tabela extra Zero de infraestrutura adicional Quando existe um identificador natural de negócio já enviado pelo cliente

Para dinheiro, a escolha é a primeira. O ponto decisivo é a atomicidade: gravar a chave em um sistema (Redis) e o efeito em outro (Postgres) reintroduz o problema de dois commits sem transação distribuída. Se o processo cair entre os dois, o sistema fica em um estado em que a chave existe mas a cobrança não — e a retentativa passa a receber uma resposta de sucesso para algo que nunca aconteceu. É o terceiro nível de gravidade descrito acima, agora causado pela própria proteção.

A terceira opção é subestimada. Quando o domínio já tem um identificador que o cliente controla — número de pedido, referência externa do parceiro, código de remessa —, uma unique constraint nesse campo entrega idempotência sem tabela auxiliar, sem TTL e sem código de limpeza. A limitação é que ela não guarda a resposta original: na segunda tentativa o servidor descobre a violação de constraint e precisa buscar o recurso existente para responder o mesmo corpo. Para APIs cujo retorno é o próprio recurso, isso é suficiente e vale a simplicidade.

O caso difícil: a segunda requisição chega com a primeira ainda em voo

Esse é o cenário que separa implementação real de implementação de exemplo. O cliente deu timeout aos 30 segundos e reenviou; a primeira requisição continua rodando no servidor, chamando o adquirente, e vai levar mais 10 segundos.

A verificação ingênua — select, e se não achar, processa — coloca as duas requisições no mesmo caminho, porque no instante do select a primeira ainda não escreveu nada. O resultado é a cobrança dupla que a idempotência deveria impedir.

A correção é inverter a ordem: escrever a chave primeiro, em estado em_andamento, e deixar o banco decidir quem ganhou a corrida. Quem consegue inserir processa; quem recebe violação de unicidade sabe que perdeu e responde de acordo.

-- 1. tentativa de reserva; quem perder recebe violação de unicidade
INSERT INTO idempotencia (consumidor_id, rota, chave, body_hash, estado)
VALUES ($1, $2, $3, $4, 'em_andamento');

-- 2. processa o efeito e finaliza na MESMA transação
UPDATE idempotencia
   SET estado = 'concluido',
       status_http = $5,
       resposta = $6,
       concluido_em = now()
 WHERE consumidor_id = $1 AND rota = $2 AND chave = $3;

Resta definir o que responder ao perdedor da corrida. Há duas escolhas defensáveis e uma ruim.

A ruim é bloquear e esperar a primeira terminar para devolver a mesma resposta. Parece elegante e ocupa um worker por toda a duração da requisição original — sob incidente, quando todo mundo está reprocessando, isso esgota o pool de conexões e derruba o serviço inteiro. É o mecanismo clássico de amplificação de falha.

A primeira escolha defensável é responder 409 Conflict imediatamente, sinalizando que aquela chave está em processamento e que o cliente deve tentar de novo em alguns segundos. É simples, libera o worker na hora e transfere a espera para o cliente, que é quem tem o contexto para decidir.

A segunda é responder 202 Accepted com um identificador de acompanhamento, tornando o endpoint assíncrono nesse caso. Mais trabalho dos dois lados, melhor para operações longas.

Entre as duas: 409 para APIs síncronas cujo processamento fica na casa de segundos, que é a maioria; 202 quando a operação envolve fila, terceiro lento ou processamento que pode passar de dez segundos. O critério não é a elegância, é quanto tempo um worker fica preso durante um pico de retentativas.

Há ainda o registro órfão: o processo morreu depois do insert em em_andamento e nunca concluiu. Sem tratamento, aquela chave fica travada em 409 para sempre. A saída é considerar expirado todo registro em_andamento mais velho que o timeout máximo da operação — algo entre 60 e 120 segundos para APIs síncronas — e permitir que uma nova tentativa assuma o lugar.

O que verificar antes de considerar pronto

Testes de idempotência que passam sempre costumam ser testes sequenciais, e sequência é justamente o caso fácil. A bateria mínima tem cinco cenários, e três deles exigem concorrência real.

  • Mesma chave, mesmo corpo, sequencial: a segunda chamada devolve status e corpo idênticos aos da primeira, sem novo efeito colateral.
  • Mesma chave, mesmo corpo, concorrente: N chamadas disparadas em paralelo produzem exatamente um efeito; as demais recebem 409 ou a resposta gravada.
  • Mesma chave, corpo diferente: retorna 422 e não executa nada.
  • Falha no meio: matar o processo entre o insert da chave e a conclusão deve deixar o sistema em estado recuperável — a retentativa após a janela de expiração processa normalmente, sem duplicar.
  • Chave expirada: passada a janela de retenção, a mesma chave é tratada como requisição nova. Confirme que isso é aceitável para o domínio antes de encurtar o TTL.

O quarto cenário é o que mais revela defeito e o menos testado, porque exige injetar falha no meio da transação. Vale o esforço de escrever um teste que aborte a conexão logo após o primeiro insert: é ele que diferencia um sistema que sobrevive a deploy no meio do pico de um que gera pendência de conciliação.

O próximo passo natural, com a idempotência de escrita estabilizada, é aplicar a mesma disciplina ao consumo de eventos — webhooks recebidos de terceiros carregam garantia at-least-once e chegam repetidos por definição, com o agravante de que a chave, ali, é escolhida por quem envia.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *