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:
- A requisição nunca chegou à API;
- A API recebeu a requisição, mas falhou antes da cobrança;
- 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,FAILEDeUNKNOWN.
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.
| Conceito | O que garante | Limite |
|---|---|---|
| Idempotência | Repetir a operação não repete o efeito protegido | Depende de como o efeito foi definido |
| Deduplicação | Identifica entradas já vistas | Exige identidade, armazenamento e retenção |
| Exactly once | Um efeito ocorre exatamente uma vez dentro de um limite declarado | Nã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ão | Benefício | Custo ou risco |
|---|---|---|
| Persistir a resposta | Retry rápido e previsível | Armazenamento e dados desatualizados |
| Usar TTL curto | Menor custo de retenção | Duplicatas após a expiração |
| Reservar a chave antes do efeito | Controla concorrência | Exige recuperar estados PROCESSING |
| Usar cache para as chaves | Baixa latência | Evicção, perda e consistência do cache |
| Aplicar idempotência em cada serviço | Protege cada fronteira | Mais 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:
- Qual é exatamente o efeito que não pode ser duplicado?
- Quem cria a chave e quando uma nova chave deve ser usada?
- Qual é o escopo de unicidade da chave?
- Como o servidor valida que a mesma chave representa os mesmos dados?
- A reserva da chave e o efeito são atômicos?
- O que acontece com requisições concorrentes enquanto a primeira processa?
- Como operações presas em estado incerto são reconciliadas?
- Por quanto tempo as chaves ficam armazenadas?
- Quais serviços externos também precisam reconhecer a identidade da operação?
- Quais métricas e alertas mostram conflitos, retries e operações presas?
Perguntas de entrevista
- Qual é a diferença entre uma operação idempotente e uma operação deduplicada?
- Como você implementaria idempotência em um endpoint de criação de pagamentos?
- Por que verificar a chave com
SELECTe depois inseri-la pode causar duplicação? - O que a API deve fazer quando recebe a mesma chave com um payload diferente?
- Como tratar duas requisições simultâneas com a mesma chave?
- Qual é a janela de falha ao chamar um gateway externo e depois atualizar o banco?
- Como tornar idempotente um consumidor com entrega at least once?
- Por que uma garantia exactly once do broker não garante exatamente um e-mail enviado?
- 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.