Chave de idempotência em APIs: como evitar que uma repetição vire duas operações

Uma pessoa clica em “pagar”, a rede oscila e o cliente não recebe a resposta. O que ele faz? Tenta de novo. Um worker perde a conexão depois de enviar um POST? Tenta de novo. Um proxy entende que houve falha transitória? Você já sabe. Repetir requisições é comportamento normal de sistemas distribuídos, e é por isso que endpoints que criam efeitos precisam pensar em idempotência antes de serem chamados de confiáveis.

Uma chave de idempotência dá à API um identificador para reconhecer que duas tentativas representam a mesma intenção do cliente. Em vez de criar dois pedidos, duas cobranças ou dois envios de e-mail, o servidor associa a segunda chamada ao resultado que já começou ou já terminou de processar.

O problema não é só POST

HTTP chama PUT e DELETE de idempotentes porque a repetição pretendida leva ao mesmo estado do recurso. Já um POST /payments geralmente representa uma nova ação. O ponto crítico é que o cliente pode não saber se a primeira ação foi concluída: a resposta pode se perder depois que o servidor já gravou no banco.

POST /orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: "b902b6f0-7474-4e18-bced-5ad0c2d0d4d1"

{ "cartId": "cart_42", "paymentMethod": "card" }

Na primeira chamada, o backend registra a chave junto do escopo da operação, um fingerprint do payload e o resultado ou estado de processamento. Se a mesma chave reaparecer com o mesmo conteúdo, ele devolve a resposta armazenada ou informa que a operação ainda está em andamento. Se reaparecer com um payload diferente, é erro de contrato: reutilizar a chave para outra intenção é pedir para o sistema adivinhar.

O fluxo precisa ser atômico

O anti-padrão clássico é consultar se a chave existe, executar a operação e só depois gravar a chave. Duas requisições concorrentes conseguem atravessar essa janela e ambas fazem o trabalho. A proteção deve usar uma restrição única no armazenamento ou uma operação atômica de “criar se não existir”, preferencialmente na mesma transação que registra o efeito de negócio quando isso for possível.

begin transaction;

insert into idempotency_keys (scope, key, request_hash, status)
values ('create-order:user_7', :key, :hash, 'processing');

-- Se a chave já existir, não crie outro pedido.
insert into orders (customer_id, total) values (:customerId, :total);

update idempotency_keys
set status = 'completed', response_body = :response
where scope = 'create-order:user_7' and key = :key;

commit;

O desenho exato muda conforme o banco e o tipo de efeito. Para uma chamada a um provedor externo, pode ser necessário combinar esse registro com uma outbox, fila ou identificador idempotente também do lado do provedor. A chave no seu endpoint não desfaz uma integração que cobra duas vezes se você não propagar a mesma disciplina para fora.

Escopo e prazo importam

Uma chave global sem contexto pode colidir entre usuários ou endpoints. Faça o escopo incluir a operação e, quando fizer sentido, o cliente autenticado ou o tenant. Também defina uma política de expiração de acordo com o risco: reter para sempre pode ser caro; apagar cedo demais devolve a duplicação justamente quando um cliente volta a tentar depois de uma falha longa.

O nome do cabeçalho faz parte do contrato da sua API; não existe aqui uma regra final do HTTP que dispense documentação. Explique quais endpoints aceitam a chave, por quanto tempo ela vale, qual identidade entra no escopo e o que acontece quando o payload não coincide.

Modele a chave como uma máquina de estados

Guardar apenas “existe” ou “não existe” é insuficiente. Uma requisição pode estar em processamento, ter sido concluída, ter falhado antes de produzir efeito ou ter ficado num estado incerto depois de falar com um serviço externo. A resposta para a repetição muda em cada caso.

  • processing: outra tentativa possui a chave; devolva conflito temporário ou um recurso de consulta, sem iniciar trabalho paralelo.
  • completed: repita status, corpo e cabeçalhos relevantes da resposta conhecida.
  • failed_safe: nenhum efeito ocorreu; permita uma nova tentativa controlada.
  • unknown: o efeito externo pode ter ocorrido; reconcilie antes de tentar novamente.

Inclua um prazo de lease em processing. Se o worker morrer, a chave não pode bloquear para sempre, mas o vencimento também não deve autorizar cegamente outra cobrança. Um processo de recuperação verifica o pedido local e o provedor, conclui o registro quando encontra evidência do efeito e só libera nova execução quando consegue provar que nada aconteceu.

O hash deve representar a intenção canônica

Calcular o hash sobre o JSON bruto torna espaços e ordem de propriedades diferenças de negócio. Normalize o payload para uma representação canônica e inclua somente campos que definem a intenção. Autenticação, endpoint e tenant pertencem ao escopo; um timestamp gerado pelo cliente talvez impeça a repetição legítima.

Idempotência também precisa combinar com a política de retries, backoff e prazo total. A chave impede duplicação; ela não impede que uma tempestade de clientes sobrecarregue o endpoint. Quando a operação publica eventos, a transactional outbox fecha a janela entre a gravação do estado e a entrega assíncrona.

Teste a falha no ponto em que a resposta desaparece

  1. envie duas requisições simultâneas com a mesma chave e confirme um único efeito;
  2. repita a chave com payload diferente e espere erro de contrato;
  3. interrompa o worker depois do commit, mas antes da resposta;
  4. simule timeout depois de o provedor externo aceitar a operação;
  5. expire o registro e verifique se a política ainda impede uma duplicação tardia.

O caso decisivo não é a repetição depois de uma resposta 200. É a repetição quando ninguém sabe se o primeiro processamento chegou ao fim. Se o desenho consegue reconciliar esse intervalo sem adivinhar, a chave está protegendo a operação real, não apenas o caminho feliz.

Fontes