Idempotência Explicada e o Que Ela Significa para a Sua API
Chaves de idempotência explicadas: evite requisições API duplicadas, corrija race conditions e faça retry de POST com claims atômicos e transações.
Uma operação é idempotente quando executá-la várias vezes deixa o sistema no mesmo estado que executá-la uma única vez. Envie a mesma requisição duas vezes e nada de extra acontece: nenhum segundo pedido, nenhuma segunda cobrança, nenhuma segunda conta.
A palavra costuma aparecer em uma de duas situações: uma cobrança duplicada em produção, ou uma API de pagamentos que exige um header Idempotency-Key sem dizer muita coisa sobre o que o servidor faz com ele. Este artigo cobre as duas metades desse contrato: o que o cliente faz com a chave, e o que o servidor faz para tornar uma requisição repetida inofensiva, em vez de apenas quase sempre inofensiva.
Principais Conclusões
- Uma chave de idempotência tem três estados, não dois: ausente, em andamento (in flight) e concluída. Uma requisição que encontra a chave em andamento deve receber um 409 Conflict, e não uma segunda execução.
- Verificar se uma chave existe e só depois gravá-la é exatamente a condição de corrida; a única reivindicação segura é um único insert atômico antes de qualquer trabalho começar.
- O resultado armazenado precisa ser confirmado (commit) na mesma transação de banco de dados que a mudança de negócio; confirmá-los separadamente apenas desloca a corrida.
- O cliente gera a chave antes da primeira tentativa, reutiliza-a em cada retentativa e nunca a deriva de um hash do corpo da requisição.
- Teste disparando duas requisições idênticas no mesmo instante. Um teste de duplicidade que as executa uma após a outra passa mesmo quando o código tem condição de corrida.
O Que a Idempotência Previne?
A idempotência protege você de requisições duplicadas, e requisições duplicadas são algo comum, não exótico. Um usuário dá um duplo clique em “enviar” antes de a página reagir. Uma biblioteca cliente atinge o timeout esperando uma resposta e reenvia. Um proxy ou service mesh faz uma retentativa após uma conexão interrompida sem que a aplicação saiba. Em todos os casos a primeira requisição pode ter tido sucesso, então a segunda, processada ingenuamente, cria um segundo pedido ou movimenta dinheiro duas vezes. Session replays de bugs de submissão duplicada normalmente mostram a versão mais banal: o usuário apertando “enviar” de novo enquanto o spinner ainda está na tela, que é a metade client-side exatamente do mesmo problema que a chave resolve no servidor.
Por Que as Retentativas Continuam Chegando
Uma retentativa não é um defeito. Clientes HTTP, apps móveis e toda a infraestrutura no meio do caminho reenviam requisições após timeouts por design, porque uma resposta perdida é indistinguível de uma requisição perdida. Você não consegue impedir que retentativas cheguem; só consegue tornar seu endpoint seguro para receber a mesma requisição duas vezes.
A RFC 9110 define PUT e DELETE como idempotentes, enquanto POST não é, e PATCH, definido na RFC 5789, também não é idempotente. O rótulo do método nunca torna seu handler seguro por si só; idempotência é uma propriedade da sua implementação, não do verbo.
A Metade do Contrato que Cabe ao Cliente
O cliente gera a chave de idempotência antes da primeira tentativa, envia a chave idêntica em cada retentativa daquela operação e usa uma chave nova apenas para uma operação genuinamente nova. Uma string aleatória de alta entropia, como um UUID, funciona. A orientação da Stripe é um UUID V4, com limite de 255 caracteres, e nada sensível dentro da própria chave: nenhum endereço de e-mail ou outros identificadores pessoais, porque chaves aparecem em logs.
Nunca derive a chave de um hash do corpo da requisição. Dois pedidos que por acaso sejam idênticos, como o mesmo cliente comprando o mesmo item duas vezes seguidas, gerariam o mesmo hash e seriam fundidos em um só. Um hash diz se dois payloads são iguais; uma chave diz qual operação o chamador quis realizar. São trabalhos distintos. Derivar a chave de algo estável sobre o qual o usuário já está agindo, como um ID de carrinho, funciona bem, porque o carrinho representa a operação.
Note que o header Idempotency-Key é uma convenção da indústria, não um padrão ratificado. O draft do grupo de trabalho httpapi do IETF expirou na revisão 07 sem se tornar uma RFC, então cada provedor define sua própria semântica.
A Metade do Servidor: Reivindique a Chave Atomicamente
Uma chave de idempotência tem três estados, não dois: ausente, em andamento e concluída. A maioria das implementações quebradas modela apenas dois. Elas verificam se a chave existe, executam o handler e depois salvam o resultado. Isso deixa uma janela na qual duas retentativas concorrentes não encontram nada e ambas executam. A correção é reivindicar a chave com um único insert atômico antes de qualquer trabalho começar:
INSERT INTO idempotency_keys
(tenant_id, idem_key, fingerprint, state, locked_until)
VALUES
($1, $2, $3, 'in_flight', now() + interval '90 seconds')
ON CONFLICT (tenant_id, idem_key) DO NOTHING
RETURNING id;
No PostgreSQL, ON CONFLICT DO NOTHING pula o insert e RETURNING não retorna nenhuma linha para uma chave conflitante, então zero linhas retornadas significa que outra requisição é a dona dela. Leia a linha existente: se o estado for complete, reproduza (replay) o resultado armazenado; se ainda estiver in_flight, retorne 409 Conflict em vez de executar uma segunda vez. Isso corresponde ao comportamento documentado dos provedores: a Stripe retorna 409 Conflict quando uma chave é reutilizada enquanto a primeira requisição ainda está em execução, e ela não registra esse conflito contra a chave, de modo que o cliente fica livre para voltar mais tarde. A Stripe também marca uma resposta reproduzida com um header Idempotent-Replayed: true, uma cortesia barata que vale a pena copiar.
Confirme o Resultado Junto com a Mudança de Negócio
O resultado armazenado e a mudança de negócio precisam ser confirmados na mesma transação de banco de dados. Gravá-los separadamente não elimina a corrida, apenas a desloca para o intervalo entre os dois commits. Se o processo morrer depois da cobrança mas antes de a chave ser atualizada, o dinheiro já se moveu enquanto a linha ainda consta como in_flight.
BEGIN;
INSERT INTO orders (tenant_id, customer_id, total_cents)
VALUES ($1, $2, $3);
UPDATE idempotency_keys
SET state = 'complete', status_code = 201, response_body = $4
WHERE tenant_id = $1 AND idem_key = $5;
COMMIT;
Ou as duas linhas existem, ou nenhuma existe, e é exatamente esse o objetivo.
O Que Você Deve Armazenar Junto à Chave, e por Quanto Tempo?
Armazene o que quer que o handler tenha produzido, incluindo suas falhas. Sob as regras de idempotência da Stripe, o status code e o corpo da primeira tentativa são mantidos e retornados novamente na reutilização, incluindo respostas de erro e 500s. Reproduzir um erro real é mais honesto do que silenciosamente executar a operação uma segunda vez. A fronteira real é tudo aquilo que é rejeitado antes de o handler rodar. Rate limiting e autenticação ficam na frente da camada de idempotência, então essas respostas nunca são associadas à chave e continuam passíveis de retentativa.
Três opções de armazenamento, resumidamente:
- Resposta completa. Mais simples para reproduzir exatamente; o armazenamento cresce com o tamanho do payload.
- Referência ao recurso. Armazene o ID do pedido criado e reconstrua a resposta; mais leve, mas exige uma consulta adicional.
- Marcador mais fingerprint da requisição. Armazenamento mínimo; só é viável quando a resposta pode ser recalculada, e o fingerprint passa de opcional a obrigatório.
Quatro regras se aplicam independentemente da opção. Coloque a constraint de unicidade em (tenant_id, key) em vez de apenas na chave, para que um tenant não possa colidir com as chaves de outro tenant nem sair pescando por elas. Defina uma expiração: a Stripe limpa as chaves assim que elas passam da marca de 24 horas, e o princípio é sobreviver à janela de retentativas sem deixar a tabela crescer indefinidamente. Coloque um lease nas linhas em andamento (a coluna locked_until acima), para que um processo que morre no meio da requisição não bloqueie retentativas para sempre. E rejeite qualquer retentativa cujo fingerprint não corresponda ao armazenado. A mesma chave com um corpo diferente aponta para um bug no cliente, e devolver uma resposta não relacionada seria o pior desfecho.
Como Testar Idempotência Corretamente?
Dispare duas requisições idênticas com a mesma chave no mesmo instante e depois verifique que existe exatamente um recurso. Executar as duplicatas uma após a outra não prova nada, porque a primeira termina antes de a segunda olhar, então código com condição de corrida passa no teste.
KEY=$(uuidgen)
for i in 1 2; do
curl -s -o "resp_$i.json" -w "%{http_code}\n" \
-X POST http://localhost:3000/orders \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"cart_id":"c_42","total_cents":1900}' &
done
wait
Verifique que há uma linha em orders para aquele carrinho, e que os dois status codes são um 201 mais ou um 201 reproduzido ou um 409. Se ambos retornaram 201 com IDs de pedido diferentes, você tem a corrida do tipo check-then-write.
Três Coisas para Acertar
O header apenas dá a dois sistemas um nome compartilhado para uma operação. A segurança em si vem de três coisas no seu banco de dados: uma constraint de unicidade, uma reivindicação atômica e um limite de transação. Acerte essas três e seu endpoint sobrevive a qualquer cliente que faça retentativas, ou seja, todos os clientes. A mesma abordagem se aplica a consumidores de mensagens, onde a entrega at-least-once significa uma chave de deduplicação fazendo esse mesmo trabalho sob outro nome. Comece pelo seu endpoint POST mais perigoso, adicione a tabela de chaves e escreva o teste concorrente antes de confiar nele.
Perguntas Frequentes
Requisições GET e PUT precisam de chaves de idempotência?
Normalmente não. A RFC 9110 define GET como seguro e PUT e DELETE como idempotentes, então um PUT retentado que substitui integralmente um recurso deixa o mesmo estado sem precisar de chave. Chaves importam para POST, onde cada requisição cria algo novo. A exceção é um handler PUT ou DELETE com efeitos colaterais, como enviar um e-mail ou disparar um webhook, que ainda assim precisa de deduplicação no servidor.
Qual é a diferença entre uma chave de idempotência e um request ID?
Elas se comportam de formas opostas entre as retentativas. Um request ID ou correlation ID identifica uma única tentativa HTTP para fins de logging e tracing, então cada retentativa recebe um novo. Uma chave de idempotência identifica uma operação pretendida, então cada retentativa reutiliza a mesma. Um cliente que gera uma nova chave de idempotência a cada retentativa anula completamente a deduplicação, e o servidor executa a operação duas vezes.
Posso armazenar chaves de idempotência no Redis em vez do PostgreSQL?
Sim, para a reivindicação atômica: SET com a flag NX reivindica uma chave em um único passo atômico, equivalente ao padrão de insert-on-conflict. O que o Redis não pode oferecer é uma única transação que confirme o resultado da chave junto com uma linha de negócio armazenada em outro lugar. Uma queda entre a escrita no Redis e o commit no banco reabre a corrida, então manter as chaves no banco de dados de negócio é mais seguro.
O que acontece se um cliente fizer uma retentativa depois que a chave de idempotência expirou?
O servidor trata a retentativa como uma requisição totalmente nova e a executa de novo, o que pode criar uma duplicata. A Stripe, por exemplo, limpa as chaves depois de 24 horas, então uma chave reutilizada após essa janela executa a operação uma segunda vez. Defina seu período de retenção como maior do que o maior atraso de retentativa que qualquer cliente, fila ou job em lote possa plausivelmente produzir.