Um gateway de pagamento reenvia o webhook de confirmação quando não recebe 2xx em poucos segundos. A aplicação processa, credita o saldo e devolve 200 — mas devolveu no segundo 6, e o gateway já havia desistido no segundo 5. O evento chega de novo, o handler roda de novo, o saldo é creditado duas vezes. O bug não está no gateway: reentrega é comportamento documentado e esperado. Está na suposição de que cada chamada acontece uma única vez.
Essa suposição sobrevive em produção por meses porque o cenário que a quebra é raro sob carga baixa. Ela falha justamente no pico, quando a latência sobe, os timeouts começam a disparar e o volume de retries cresce — ou seja, no pior momento possível e com o maior número de registros afetados.
Onde a duplicidade nasce
Três origens distintas produzem a mesma consequência, e convém separá-las porque exigem defesas diferentes.
A primeira é o retry do cliente. Timeout de rede não informa se a operação foi executada — informa apenas que a resposta não chegou. A requisição pode ter morrido antes de tocar o servidor, depois de commitada, ou em qualquer ponto intermediário. Um cliente que reenvia após timeout está agindo corretamente; do lado do servidor, é impossível distinguir essa segunda chamada de uma intenção nova sem informação adicional.
A segunda é a entrega at-least-once de qualquer sistema de mensageria sério. Broker que garante entrega sem duplicata exige coordenação cara, e a maioria escolhe reentregar em caso de dúvida — um ack perdido é indistinguível de um consumidor que caiu. Consumidor que assume exatamente-uma-vez está assumindo algo que o broker nunca prometeu.
A terceira é o duplo clique e sua variante moderna: o componente que dispara a mutação duas vezes sob StrictMode em desenvolvimento, ou o usuário impaciente que clica de novo porque o spinner demorou. Desabilitar o botão ajuda na experiência, mas é defesa de interface — não sobrevive a um cliente que fale direto com a API, nem a uma aba duplicada.
Onde a chave de idempotência deve viver
A decisão central é quem gera a chave. A resposta é: o cliente, antes da primeira tentativa, e a mesma chave se repete em todos os retries daquela intenção.
Chave gerada pelo servidor não resolve nada, porque cada requisição produziria uma nova e a segunda seria indistinguível da primeira. Chave derivada do conteúdo — hash do corpo — falha em casos legítimos de repetição: duas transferências idênticas de R$ 50 para o mesmo destinatário no mesmo minuto podem ser duas intenções reais, e recusar a segunda é um bug tão grave quanto processar duas vezes.
O escopo da chave importa tanto quanto a origem. Ela precisa ser única por cliente e por endpoint, não globalmente. Chave global permite que um cliente colida com a chave de outro, o que transforma um mecanismo de segurança em vetor de vazamento: o segundo cliente receberia a resposta armazenada do primeiro.
O armazenamento e a condição de corrida que quase todos deixam passar
A implementação ingênua faz SELECT para verificar se a chave existe e INSERT se não existir. Sob concorrência real — exatamente o cenário do duplo clique — duas requisições fazem o SELECT ao mesmo tempo, ambas não encontram nada, e ambas prosseguem. O código parece correto em revisão e falha em produção.
A defesa correta é delegar a exclusividade ao banco, com constraint única, e tratar a violação como caminho normal de execução em vez de erro inesperado.
CREATE TABLE idempotencia (
chave text NOT NULL,
cliente_id uuid NOT NULL,
endpoint text NOT NULL,
estado text NOT NULL DEFAULT 'em_curso',
hash_corpo text NOT NULL,
resposta jsonb,
status_http int,
criado_em timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (cliente_id, endpoint, chave)
);
O fluxo passa a ser: tentar inserir com estado em_curso. Se o INSERT vencer, o processo é o dono da execução e segue. Se colidir, outra requisição já assumiu — e aí o comportamento depende do estado encontrado.
Registro concluído devolve a resposta armazenada, com o mesmo código de status da primeira vez. Registro ainda em_curso significa que a operação está acontecendo agora, e a resposta apropriada é 409: bloquear e esperar só transfere a pressão para o pool de conexões, e sob retry agressivo esgota o pool inteiro em segundos.
Há um terceiro caso, o mais silencioso: mesma chave com corpo diferente. Isso indica erro do cliente — reuso acidental de chave para outra intenção — e merece 422 com mensagem explícita. Sem o hash do corpo armazenado, esse caso passa despercebido e o cliente recebe a resposta de uma operação que não é a que ele pediu.
O que armazenar, e por quanto tempo
| Campo | Por que existe |
|---|---|
| Estado | Distingue operação em curso de concluída — sem isso, retry durante o processamento vira duplicata |
| Resposta serializada | Permite devolver o resultado original; sem ela o cliente recebe 409 e não descobre o que aconteceu |
| Status HTTP original | Retry precisa receber 201 se a primeira criou, não 200 — clientes tratam os dois de forma diferente |
| Hash do corpo | Detecta reuso de chave com conteúdo diferente, que é erro do cliente e merece 422 |
| Id do recurso criado | Facilita auditoria e reconciliação sem desserializar a resposta inteira |
A janela de retenção deve ser mais longa que o maior intervalo de retry de qualquer cliente. Gateways de pagamento costumam tentar por horas ou dias com espaçamento crescente, então retenção de 24 horas é insuficiente para esse caso. Uma semana cobre a maioria dos cenários, e a limpeza posterior é rotina de manutenção agendada, não parte do caminho quente da requisição.
Vale dimensionar antes de decidir: a tabela cresce com o volume de escritas, não com o de usuários. Uma operação com algumas centenas de milhares de escritas por dia acumula alguns milhões de linhas na janela de sete dias — volume trivial para Postgres com a chave primária certa, desde que a limpeza exista e não seja esquecida.
Como testar que a defesa funciona
Teste que dispara duas requisições em sequência não prova nada: a segunda encontra a primeira já concluída, que é o caminho fácil. O caso que quebra implementações é a concorrência real, e ele precisa ser reproduzido deliberadamente.
- Disparar N requisições com a mesma chave em paralelo e verificar que o efeito colateral ocorreu exatamente uma vez no banco, não que as respostas foram iguais.
- Matar o processo entre a gravação da chave e o commit do efeito, confirmando que a transação inteira reverte.
- Reenviar a mesma chave com corpo alterado e checar que retorna 422, não a resposta antiga.
- Reenviar depois da janela de retenção e confirmar que a operação é executada de novo — comportamento correto, e que precisa estar documentado para o cliente.
Em observabilidade, a métrica que antecipa problema é a taxa de colisão de chave por endpoint. Subida repentina indica cliente em loop de retry ou timeout mal calibrado do lado de fora, e costuma aparecer antes de qualquer reclamação de usuário.
Quando idempotência não é o instrumento certo
Operações naturalmente idempotentes não precisam de chave. Um PUT que define um campo para um valor absoluto pode ser repetido sem consequência. O problema aparece em operações relativas — incrementar saldo, acrescentar item, disparar cobrança — onde repetir significa somar de novo. Quando há liberdade de projeto, reescrever a operação em termos absolutos resolve o problema sem infraestrutura nenhuma.
Para consumo de eventos de broker, a chave costuma já existir sob outro nome: o identificador do evento. Registrar eventos processados numa tabela com constraint única resolve o mesmo problema sem inventar campo novo, e tem a vantagem de o identificador vir do produtor, que é quem sabe o que é uma repetição.
Há ainda o caso em que a operação é externa e não transacional — cobrar num provedor de pagamento, enviar e-mail. Aqui nenhuma constraint local garante nada, porque o efeito acontece fora do banco. A saída é usar a chave de idempotência do próprio provedor quando ela existe, e registrar a intenção antes da chamada externa para que uma falha no meio deixe rastro auditável em vez de silêncio.
Entre cache externo e constraint no banco relacional, a preferência prática é clara: o banco que já guarda o efeito colateral. O cache é mais rápido e perde a garantia exatamente no cenário que importa, que é o processo morrendo no meio da operação.