12k
All articles

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.

OpenReplay Team
OpenReplay Team
Idempotência Explicada e o Que Ela Significa para a Sua API

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.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.