Uma pessoa confirma uma compra. O servidor processa o pagamento, mas a resposta se perde por causa de um timeout. Sem saber se a operação terminou, o aplicativo tenta novamente.

Essa segunda tentativa deve cobrar o cartão outra vez ou devolver o resultado da primeira?

Falhas parciais como essa são comuns em sistemas distribuídos. A requisição pode chegar ao servidor, o trabalho pode ser concluído e, ainda assim, o cliente não receber a confirmação. Idempotência permite repetir a operação sem repetir o efeito de negócio.

O que é idempotência?

Uma operação é idempotente quando executá-la mais de uma vez com a mesma intenção produz o mesmo estado final que executá-la uma única vez.

f(f(x)) = f(x)

Em uma API, isso não significa que todas as respostas precisam ser byte a byte iguais ou que o servidor não fará nenhum trabalho adicional. Logs, métricas e timestamps podem mudar. A garantia importante é que o efeito de negócio protegido não seja duplicado.

Algumas operações já são naturalmente idempotentes:

  • Definir o status de um pedido como cancelado;
  • Atualizar o nome de um perfil para Ana;
  • Remover um item específico de um conjunto;
  • Consultar os dados de uma conta.

Outras não são:

  • Incrementar o saldo em R$ 100;
  • Criar um novo pedido;
  • Cobrar um cartão;
  • Enviar um e-mail;
  • Publicar uma mensagem em uma fila.

Executar definir saldo = 100 duas vezes mantém o mesmo resultado. Executar adicionar 100 ao saldo duas vezes altera o resultado. A semântica da operação, e não apenas o verbo HTTP ou o nome do endpoint, determina se ela é idempotente.

Por que sistemas repetem operações?

Redes não entregam uma confirmação perfeita de sucesso. Considere este fluxo:

Cliente          API             Gateway
   | POST /pay     |                 |
   |-------------->| cobra R$ 100    |
   |               |---------------->|
   |               |     sucesso     |
   |               |<----------------|
   |     ✕ timeout |
   |<-- resposta perdida             |

O cliente observou um timeout, mas não sabe qual destas situações aconteceu:

  1. A requisição nunca chegou à API;
  2. A API recebeu a requisição, mas falhou antes da cobrança;
  3. A cobrança terminou, mas a resposta se perdeu.

Tentar novamente é a resposta normal para falhas transitórias. Retries também aparecem em proxies, SDKs, filas, workers reiniciados e operadores que reprocessam uma DLQ. Sem idempotência, cada camada pode transformar uma falha temporária em um efeito duplicado.

Como funciona uma chave de idempotência?

Para uma operação que não é naturalmente idempotente, o cliente cria um identificador único para a intenção de negócio e o envia em todas as tentativas daquela operação.

POST /payments
Idempotency-Key: 8b6f1db8-04dc-4f75-a58b-f11c9fcd2f80
Content-Type: application/json

{
  "orderId": "order-842",
  "amount": 10000,
  "currency": "BRL"
}

O servidor usa essa chave para reconhecer repetições:

Recebe a requisição
        ↓
Procura a chave no armazenamento
        ↓
   ┌────┴────┐
 não existe  existe
   ↓           ↓
reserva       valida os dados
a chave        ↓
   ↓          devolve o resultado salvo
executa
   ↓
salva o resultado

Na primeira tentativa, o servidor registra a chave, executa a operação e associa o resultado a ela. Nas tentativas seguintes, ele devolve o resultado conhecido sem criar uma nova cobrança.

A chave representa uma intenção, não uma tentativa

O cliente deve reutilizar a mesma chave quando repete a mesma operação e criar outra chave quando inicia uma nova intenção.

Retry do pagamento do pedido 842 → mesma chave
Novo pagamento de outro pedido → nova chave

Gerar uma chave diferente a cada retry impede a deduplicação. Reutilizar eternamente a mesma chave pode fazer operações legítimas parecerem duplicadas.

A chave precisa ter um escopo

Uma UUID reduz colisões acidentais, mas o servidor também deve definir onde a chave é única. Um escopo comum combina o identificador do cliente, o endpoint e a chave:

(account_id, operation, idempotency_key)

Isso evita que dois clientes interfiram um no outro e permite regras diferentes por tipo de operação. Uma UNIQUE CONSTRAINT sobre esse conjunto é mais confiável do que uma verificação feita apenas no código da aplicação.

A mesma chave deve carregar a mesma operação

Se uma chave já usada para cobrar R$ 100 reaparecer com valor de R$ 250, o servidor não deve executar nem devolver silenciosamente o primeiro resultado como se as requisições fossem equivalentes.

Uma abordagem é armazenar um hash dos campos relevantes da requisição:

mesma chave + mesmo hash      → retry válido
mesma chave + hash diferente → conflito

O hash precisa ser calculado sobre uma representação canônica. Diferenças irrelevantes na ordem dos campos JSON não deveriam transformar a mesma intenção em outra operação.

Exemplo prático: criar um pagamento

Uma tabela simplificada pode registrar o ciclo da operação:

CREATE TABLE idempotency_records (
  account_id       UUID        NOT NULL,
  operation        TEXT        NOT NULL,
  idempotency_key  TEXT        NOT NULL,
  request_hash     TEXT        NOT NULL,
  status           TEXT        NOT NULL,
  resource_id      UUID,
  response_body    JSONB,
  created_at       TIMESTAMPTZ NOT NULL,
  expires_at       TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (account_id, operation, idempotency_key)
);

O fluxo da API pode ser modelado assim:

1. Tentar inserir a chave com status PROCESSING
2. Se já existir:
   a. rejeitar se o request_hash for diferente
   b. devolver o resultado se estiver COMPLETED
   c. informar que ainda está PROCESSING
3. Criar o pagamento
4. Salvar resource_id, resposta e status COMPLETED
5. Devolver a resposta

A inserção da chave e a criação do registro local de pagamento devem compartilhar uma transação quando pertencem ao mesmo banco. Assim, o sistema não confirma uma sem registrar a outra.

BEGIN;

INSERT INTO idempotency_records (...)
VALUES (..., 'PROCESSING', ...);

INSERT INTO payments (...)
VALUES (...)
RETURNING id;

UPDATE idempotency_records
SET status = 'COMPLETED', resource_id = :payment_id
WHERE account_id = :account_id
  AND operation = 'create-payment'
  AND idempotency_key = :key;

COMMIT;

Se duas requisições com a mesma chave chegarem ao mesmo tempo, a restrição de unicidade permite que apenas uma faça a inserção. A outra observa o conflito e consulta o registro existente.

E quando existe uma chamada externa?

Uma transação de banco não inclui automaticamente o gateway de pagamento. Esta sequência ainda possui uma janela de falha:

API → gateway confirma a cobrança → processo cai → API não salva COMPLETED

Ao reiniciar, a API encontra a chave em PROCESSING, mas não sabe se o gateway cobrou o cartão. Fazer uma nova cobrança às cegas recria o problema.

Existem algumas estratégias complementares:

  • Enviar a mesma chave de idempotência ao provedor, se ele oferecer essa garantia;
  • Consultar o provedor por uma referência de negócio estável antes de repetir;
  • Persistir um identificador da tentativa antes da chamada externa;
  • Executar uma reconciliação para operações presas em PROCESSING;
  • Modelar estados explícitos, como PENDING, AUTHORIZED, FAILED e UNKNOWN.

Nenhuma tabela local consegue, sozinha, tornar atômica uma operação que atravessa sistemas independentes. O desenho precisa considerar a garantia oferecida por cada participante e como recuperar estados incertos.

Idempotência em consumidores de mensagens

Brokers frequentemente oferecem entrega at least once: uma mensagem não deve ser perdida, mas pode ser entregue novamente. Isso ocorre quando o consumidor conclui o efeito e falha antes de confirmar o processamento.

Broker → consumidor atualiza o pedido → consumidor cai → sem ACK
Broker → entrega a mesma mensagem novamente

Um consumidor idempotente registra o identificador da mensagem junto com a alteração de negócio:

BEGIN;

INSERT INTO processed_messages (consumer, message_id)
VALUES ('order-service', :message_id);

UPDATE orders
SET status = 'paid'
WHERE id = :order_id;

COMMIT;

Se a mensagem reaparecer, a restrição única em (consumer, message_id) rejeita a inserção. O consumidor reconhece que o efeito já foi aplicado e pode confirmar a mensagem sem repeti-lo.

Esse padrão funciona quando o registro de deduplicação e o efeito de negócio estão no mesmo limite transacional. Se o consumidor também envia e-mail ou chama outro serviço, cada efeito externo precisa de sua própria proteção ou ser transformado em trabalho assíncrono persistido, como no Outbox Pattern.

Idempotência, deduplicação e exactly once

Os termos estão relacionados, mas não são equivalentes.

ConceitoO que garanteLimite
IdempotênciaRepetir a operação não repete o efeito protegidoDepende de como o efeito foi definido
DeduplicaçãoIdentifica entradas já vistasExige identidade, armazenamento e retenção
Exactly onceUm efeito ocorre exatamente uma vez dentro de um limite declaradoNão se estende automaticamente a bancos, APIs e efeitos externos

Deduplicação é uma técnica comum para implementar idempotência, mas uma operação também pode ser idempotente pela própria modelagem. Definir status = 'paid' é diferente de registrar balance = balance + amount.

Uma plataforma pode anunciar processamento exactly once dentro de seu log e ainda assim o consumidor enviar dois e-mails. A pergunta útil é sempre: exatamente uma vez em qual fronteira e para qual efeito?

O que armazenar?

Há três abordagens frequentes.

Resposta completa

O registro guarda o status HTTP e o corpo devolvido. Retries recebem a mesma representação, inclusive quando a primeira resposta foi um erro definitivo.

Vantagem: reprodução simples e fiel.

Custo: mais armazenamento, cuidado com dados sensíveis e risco de a resposta ficar desatualizada.

Referência ao recurso criado

O registro guarda o identificador do pagamento ou pedido. No retry, a API consulta o recurso e monta a resposta.

Vantagem: menos duplicação de dados.

Custo: a representação pode mudar entre tentativas e o recurso precisa continuar acessível.

Apenas a marca de processamento

O registro guarda a chave, o hash e o status. É adequado quando a operação é determinística ou quando basta impedir a repetição.

Vantagem: baixo custo de armazenamento.

Custo: pode não haver informação suficiente para reconstruir o resultado original.

A escolha depende do contrato da API. Em todos os casos, evite persistir credenciais, dados completos de cartão ou outros segredos no registro de idempotência.

Expiração e janela de deduplicação

Guardar todas as chaves para sempre é caro. Removê-las cedo demais permite que um retry atrasado repita o efeito.

O TTL deve considerar:

  • O tempo máximo de retry de clientes e intermediários;
  • A retenção e o redelivery do broker;
  • Quanto tempo uma operação pode permanecer incerta;
  • Requisitos de auditoria e privacidade;
  • O custo de uma duplicação depois da expiração.

Depois que uma chave expira, a mesma requisição pode ser tratada como nova. Esse comportamento precisa fazer parte do contrato, não ser apenas uma configuração invisível do cache.

Para efeitos críticos, uma restrição de negócio permanente pode complementar o TTL. Um pagamento pode expirar sua chave de idempotência e ainda ter uma referência externa única ligada ao pedido.

Vantagens

  • Torna retries seguros diante de timeouts e falhas transitórias;
  • Evita efeitos duplicados com alto custo financeiro ou operacional;
  • Simplifica a recuperação de workers e o reprocessamento de mensagens;
  • Oferece ao cliente uma forma estável de consultar o resultado de uma intenção;
  • Reduz correções manuais e reconciliações causadas por duplicatas.

Limitações e trade-offs

DecisãoBenefícioCusto ou risco
Persistir a respostaRetry rápido e previsívelArmazenamento e dados desatualizados
Usar TTL curtoMenor custo de retençãoDuplicatas após a expiração
Reservar a chave antes do efeitoControla concorrênciaExige recuperar estados PROCESSING
Usar cache para as chavesBaixa latênciaEvicção, perda e consistência do cache
Aplicar idempotência em cada serviçoProtege cada fronteiraMais estado e contratos distribuídos

Erros comuns

Confiar apenas no cliente

Desabilitar o botão depois do primeiro clique melhora a experiência, mas não protege contra retries automáticos, duas abas, chamadas diretas ou respostas perdidas. A garantia precisa existir no servidor.

Fazer SELECT antes de INSERT

Duas requisições concorrentes podem observar a ausência da chave e executar o efeito. Use uma inserção atômica, uma restrição única ou outra forma de compare-and-set.

Gerar uma nova chave a cada retry

O servidor vê operações diferentes e executa todas. A chave deve nascer com a intenção e sobreviver às tentativas.

Reutilizar a chave com dados diferentes

Sem validar o conteúdo, o servidor pode devolver o resultado de outra operação. Armazene e compare um hash dos campos que definem a intenção.

Usar somente memória local

Uma instância diferente não conhece as chaves já processadas, e um reinício apaga o histórico. Memória local só é suficiente quando a arquitetura garante afinidade, persistência e recuperação compatíveis com o requisito — condições raras para efeitos críticos.

Achar que uma chave resolve chamadas externas

A chave protege apenas a fronteira que a reconhece. Banco, broker, gateway e provedor de e-mail não compartilham automaticamente o mesmo estado. Propague uma referência estável e projete a recuperação em cada limite.

Marcar como concluído antes do efeito

Se o registro fica COMPLETED e o processo falha antes da operação real, os retries serão descartados embora o trabalho nunca tenha acontecido. O estado deve refletir com precisão o ponto alcançado pelo workflow.

Repetir qualquer erro para sempre

Retries ajudam em falhas transitórias, não em dados inválidos ou regras de negócio rejeitadas. Defina quais resultados são finais, quais podem ser repetidos e use limite de tentativas com backoff e jitter.

Checklist de projeto

Antes de considerar uma operação idempotente, responda:

  1. Qual é exatamente o efeito que não pode ser duplicado?
  2. Quem cria a chave e quando uma nova chave deve ser usada?
  3. Qual é o escopo de unicidade da chave?
  4. Como o servidor valida que a mesma chave representa os mesmos dados?
  5. A reserva da chave e o efeito são atômicos?
  6. O que acontece com requisições concorrentes enquanto a primeira processa?
  7. Como operações presas em estado incerto são reconciliadas?
  8. Por quanto tempo as chaves ficam armazenadas?
  9. Quais serviços externos também precisam reconhecer a identidade da operação?
  10. Quais métricas e alertas mostram conflitos, retries e operações presas?

Perguntas de entrevista

  1. Qual é a diferença entre uma operação idempotente e uma operação deduplicada?
  2. Como você implementaria idempotência em um endpoint de criação de pagamentos?
  3. Por que verificar a chave com SELECT e depois inseri-la pode causar duplicação?
  4. O que a API deve fazer quando recebe a mesma chave com um payload diferente?
  5. Como tratar duas requisições simultâneas com a mesma chave?
  6. Qual é a janela de falha ao chamar um gateway externo e depois atualizar o banco?
  7. Como tornar idempotente um consumidor com entrega at least once?
  8. Por que uma garantia exactly once do broker não garante exatamente um e-mail enviado?
  9. Como escolher o TTL de uma chave de idempotência?

Próximos conteúdos

Para continuar, estude retry com exponential backoff e jitter, Dead Letter Queue, Outbox Pattern e Saga Pattern. Esses temas mostram como repetir operações sem sobrecarregar dependências, publicar eventos de forma confiável e coordenar workflows que atravessam vários serviços.