8kdev – My Blog https://8k.dev.br My WordPress Blog Fri, 18 Sep 2026 23:36:01 +0000 pt-BR hourly 1 https://wordpress.org/?v=7.1.1 Idempotência em endpoints de escrita: onde a chave deve viver para o retry não cobrar duas vezes https://8k.dev.br/idempotencia-em-endpoints-de-escrita-onde-a-chave-deve-viver-para-o-retry-nao-cobrar-duas-vezes/ https://8k.dev.br/idempotencia-em-endpoints-de-escrita-onde-a-chave-deve-viver-para-o-retry-nao-cobrar-duas-vezes/#respond Fri, 18 Sep 2026 23:36:01 +0000 https://8k.dev.br/idempotencia-em-endpoints-de-escrita-onde-a-chave-deve-viver-para-o-retry-nao-cobrar-duas-vezes/ 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.

A chave precisa ser gravada na mesma transação que o efeito colateral. Gravar em Redis antes e no banco depois abre uma janela em que o processo morre entre os dois e a operação fica registrada como feita sem ter sido — o pior dos dois mundos, porque o retry seguinte será recusado.

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.

]]>
https://8k.dev.br/idempotencia-em-endpoints-de-escrita-onde-a-chave-deve-viver-para-o-retry-nao-cobrar-duas-vezes/feed/ 0