Você já clicou duas vezes no botão de pagar porque a tela travou? E recebeu duas cobranças?
Isso é um problema de idempotência — ou melhor, da falta dela.
Idempotência é uma daquelas palavras que parecem acadêmicas demais pra importar no dia a dia. Mas ela está por trás de praticamente todo sistema confiável que você usa. E se você desenvolve software que lida com dinheiro, filas, webhooks ou qualquer coisa que possa ser executada mais de uma vez — você precisa entender isso.
A definição sem enrolação
Uma operação é idempotente quando executá-la uma vez ou dez vezes produz o mesmo resultado.
Exemplo mais simples do mundo:
SET user.email = "billy@email.com"
Executar isso 1 vez ou 100 vezes dá no mesmo. O email vai ser billy@email.com. Isso é idempotente.
Agora compare com:
UPDATE conta SET saldo = saldo + 100
Executar 1 vez: saldo +100. Executar 2 vezes: saldo +200. Cada execução muda o estado. Isso não é idempotente.
Por que isso importa na vida real
Na teoria, cada request HTTP acontece exatamente uma vez. Na prática, o mundo é caótico:
- O usuário clica duas vezes no botão
- A rede cai no meio da request e o client faz retry
- O load balancer reenvia a request porque achou que deu timeout
- O webhook do Stripe é disparado duas vezes
- O worker da fila processa o mesmo job duas vezes porque o ACK se perdeu
Em todos esses cenários, sua operação vai ser executada mais de uma vez. Se ela não for idempotente, você tem um problema.
Cenário real: cobrança duplicada
O usuário clica em "Pagar R$49,90". A request vai pro backend, o pagamento é processado, mas a resposta demora. O frontend não recebe confirmação. O usuário clica de novo.
Sem idempotência: duas cobranças de R$49,90. O usuário reclama, você reembolsa manualmente, perde confiança.
Com idempotência: a segunda request é identificada como duplicata. O sistema retorna o resultado da primeira execução. Uma cobrança, zero problema.
Os verbos HTTP e idempotência
O protocolo HTTP já foi pensado com isso em mente:
| Verbo | Idempotente? | Por quê |
|---|---|---|
| GET | Sim | Só lê dados, não muda nada |
| PUT | Sim | Substitui o recurso inteiro — repetir dá no mesmo |
| DELETE | Sim | Deletar algo que já foi deletado = mesmo estado final |
| POST | Não | Cada chamada pode criar um novo recurso |
| PATCH | Depende | Se é "set campo X = Y", sim. Se é "incrementa X", não |
O POST é o problemático. E é justamente o verbo que usamos pra criar pedidos, processar pagamentos, enviar mensagens. Tudo que tem efeito colateral real.
Como tornar operações idempotentes
Existem três padrões principais:
1. Idempotency Key
O client gera um identificador único (UUID) e envia junto com a request. O servidor armazena esse ID e o resultado. Se a mesma key aparecer de novo, retorna o resultado salvo sem executar nada.
// No controller Laravel
public function processPayment(Request $request)
{
$key = $request->header("Idempotency-Key");
$cached = Cache::get("idempotency:{$key}");
if ($cached) {
return response()->json($cached);
}
$result = $this->paymentService->charge($request->all());
Cache::put("idempotency:{$key}", $result, now()->addHours(24));
return response()->json($result);
}
É exatamente assim que o Stripe funciona. Toda request de pagamento aceita um header Idempotency-Key. Se você mandar a mesma key duas vezes, o Stripe retorna o resultado da primeira execução.
2. Operações naturalmente idempotentes
Às vezes, basta redesenhar a operação:
-- Não idempotente
UPDATE conta SET saldo = saldo + 100;
-- Idempotente (com referência única)
INSERT INTO transacoes (id, conta_id, valor)
VALUES ("txn_abc123", 42, 100)
ON DUPLICATE KEY UPDATE id = id; -- noop se já existe
Em vez de "incrementar saldo", você "registra uma transação com ID único". Se a transação já existe, nada acontece. O saldo é calculado a partir das transações — sempre correto, não importa quantas vezes você tente inserir.
3. Detecção de duplicatas
Pra webhooks e eventos, use um registro do que já foi processado:
public function handleWebhook(Request $request)
{
$eventId = $request->input("event_id");
if (ProcessedEvent::where("event_id", $eventId)->exists()) {
return response()->json(["status" => "already_processed"]);
}
DB::transaction(function () use ($request, $eventId) {
// Processa o evento
$this->processEvent($request->all());
// Marca como processado
ProcessedEvent::create(["event_id" => $eventId]);
});
return response()->json(["status" => "processed"]);
}
O segredo é que a marcação e o processamento acontecem na mesma transação. Se o processamento falha, a marcação também falha — e o retry vai funcionar corretamente.
Filas e jobs: o campo minado
Se você usa filas (Redis, SQS, RabbitMQ), idempotência não é opcional — é obrigatória.
Por quê? Porque filas garantem at-least-once delivery, não exactly-once. Seu job vai ser executado mais de uma vez em algum momento. Pode ser por timeout, por crash do worker, por rebalanceamento de partições.
No Laravel, isso é especialmente relevante com Horizon:
class ProcessPaymentJob implements ShouldQueue
{
public function __construct(
private string $transactionId,
private int $userId,
private int $amount
) {}
public function handle()
{
// Guard de idempotência
if (Payment::where("transaction_id", $this->transactionId)->exists()) {
return; // Já processado, sai silenciosamente
}
// Processa o pagamento
Payment::create([
"transaction_id" => $this->transactionId,
"user_id" => $this->userId,
"amount" => $this->amount,
]);
}
}
O transaction_id é a chave. Se o job rodar duas vezes com o mesmo ID, a segunda execução é um noop.
Onde a maioria erra
1. Confiar que "não vai acontecer"
Vai. Em produção, com carga real, tudo que pode ser executado duas vezes será executado duas vezes. Lei de Murphy aplicada a sistemas distribuídos.
2. Idempotência só no endpoint, não no domínio
De nada adianta ter uma idempotency key no controller se o service por baixo faz saldo += valor sem verificação. A idempotência precisa descer até a camada que muda estado.
3. Cache sem TTL
Se você usa idempotency key com cache, defina um TTL razoável (24-48h). Senão, sua tabela de keys cresce infinitamente.
4. Ignorar o problema em webhooks
Webhooks de terceiros (Stripe, Asaas, PagSeguro) vão enviar o mesmo evento mais de uma vez. A documentação deles avisa. Se você não trata, vai processar o mesmo pagamento, a mesma notificação, o mesmo cancelamento duas vezes.
Idempotência no mundo real: como uso no HubNews
No HubNews, o pipeline de notícias processa centenas de artigos por dia. Cada artigo passa por RSS → Curadoria → Escrita → FactCheck → Tradução → Imagem → Publicação.
Se qualquer etapa falha e entra em retry, o sistema precisa lidar com isso sem duplicar artigos, sem gerar duas imagens, sem publicar a mesma notícia duas vezes nas redes sociais.
A solução: cada artigo tem um hash único baseado no conteúdo original do RSS. Se o pipeline tenta processar o mesmo hash, ele detecta a duplicata via embedding similarity (threshold 0.82) e faz merge em vez de criar um novo registro.
Nas redes sociais, cada post tem um ID de referência. O sistema verifica se aquele artigo já foi publicado no Twitter/LinkedIn/Instagram antes de disparar. Se já foi, pula. Simples, robusto, idempotente.
Checklist rápido
Antes de colocar qualquer endpoint em produção, pergunte:
- O que acontece se essa request for enviada duas vezes?
- Meus jobs de fila são seguros pra retry?
- Webhooks de terceiros estão protegidos contra duplicatas?
- Operações financeiras têm idempotency key ou transaction ID?
- A detecção de duplicata e o processamento estão na mesma transação?
Se a resposta pra qualquer uma dessas for "não sei" — você tem trabalho a fazer.
O takeaway
Idempotência não é um conceito teórico de ciência da computação. É uma propriedade de design que separa sistemas que funcionam em condições ideais de sistemas que funcionam no mundo real.
Redes falham. Usuários clicam duas vezes. Workers crasham. Webhooks repetem. A pergunta não é se sua operação vai ser executada mais de uma vez — é quando.
Sistemas idempotentes tratam o retry como um cenário normal, não como uma exceção. E isso é a diferença entre um sistema que dá problema toda semana e um que roda em paz.