Existe um momento em toda integração em que ninguém sabe o que aconteceu.
O cliente mandou a requisição. O servidor processou, gravou, cobrou. E aí a conexão caiu antes da resposta chegar. Do lado do cliente, o que se vê é um timeout. Ele não tem como distinguir “não chegou” de “chegou e funcionou, mas a resposta se perdeu”.
O que ele vai fazer é tentar de novo. Sempre.
Se a sua API não estiver preparada, o cliente foi cobrado duas vezes, e você vai descobrir pelo suporte.
O que idempotência significa aqui
Uma operação é idempotente quando executá-la várias vezes tem o mesmo efeito que executá-la uma vez.
GET é naturalmente idempotente. DELETE também, na prática. PUT deveria ser. O problema mora no POST, que é justamente onde ficam as operações que custam dinheiro.
A solução não é transformar POST em outra coisa. É deixar o cliente dizer, explicitamente, que aquela requisição é a mesma de antes.
A chave de idempotência
O cliente gera um identificador único por intenção e manda no cabeçalho:
POST /payments
Idempotency-Key: 8f14e45f-ea0a-4b3d-9c8f-2b1d7a6e0c11
Content-Type: application/json
{ "amount": 25000, "currency": "BRL", "order_id": "..." }
A chave representa a intenção, não a requisição. Se o cliente retentar a mesma cobrança, manda a mesma chave. Se for uma cobrança nova, chave nova. Quem gera é o cliente, porque só ele sabe se é retentativa ou operação nova.
A implementação que aguenta concorrência
A versão ingênua é procurar a chave, e se não achar, processar. Isso tem uma corrida no meio: duas requisições simultâneas com a mesma chave passam as duas pela busca antes de qualquer uma gravar.
O que funciona é deixar o banco arbitrar.
create table idempotency_key (
key text primary key,
request_hash char(64) not null,
status text not null, -- 'in_progress' | 'completed'
response_status int,
response_body jsonb,
created_at timestamptz not null default now(),
expires_at timestamptz not null
);
O fluxo:
async function withIdempotency(key: string, req: Request, handler: Handler) {
const hash = sha256(canonical(req.body));
try {
// quem conseguir inserir, processa. os outros perdem a corrida no banco.
await db.insert("idempotency_key", {
key,
request_hash: hash,
status: "in_progress",
expires_at: in24Hours(),
});
} catch (e) {
if (!isUniqueViolation(e)) throw e;
const existing = await db.findOne("idempotency_key", { key });
// mesma chave, corpo diferente: o cliente errou, e é melhor gritar.
if (existing.request_hash !== hash) {
throw new ConflictException("Idempotency-Key ja usada com outro payload");
}
if (existing.status === "in_progress") {
throw new ConflictException("Requisicao em processamento, tente de novo");
}
return replay(existing); // devolve a resposta original, igualzinha
}
const result = await handler(req);
await db.update("idempotency_key", { key }, {
status: "completed",
response_status: result.status,
response_body: result.body,
});
return result;
}
Três detalhes que separam isso de uma versão que só parece funcionar.
O insert vem antes do processamento, e a constraint de chave primária é o mecanismo de exclusão. Sem SELECT antes, sem lock explícito, sem corrida.
O hash do corpo é comparado. Chave repetida com payload diferente é bug do cliente, e devolver a resposta antiga nesse caso esconde o problema dele até virar prejuízo.
A resposta gravada é devolvida na íntegra. Não um 200 genérico: a mesma resposta, com o mesmo ID de pagamento. Quem retentou precisa conseguir seguir o fluxo normalmente.
Guardar a chave dentro da mesma transação
Se a gravação da chave e o efeito da operação estiverem em transações separadas, existe uma janela onde o dinheiro saiu e a chave não foi marcada como concluída. A retentativa processa de novo.
O efeito e a marcação de conclusão pertencem à mesma transação. Quando o efeito estiver num sistema externo, que não compartilha transação com o seu banco, você precisa de um registro local do “eu já mandei isso” antes de mandar, e de reconciliação depois. O padrão outbox existe exatamente pra costurar esses dois mundos.
Expiração
Chave guardada pra sempre vira tabela gigante. Vinte e quatro horas cobre a esmagadora maioria dos casos de retentativa. Documente a janela, porque o cliente precisa saber que reenviar a mesma chave uma semana depois vai processar de novo.
Do lado de quem consome
Se você é quem chama uma API externa, gere a chave antes da primeira tentativa e guarde junto da sua intenção. O erro clássico é gerar a chave dentro da função de retry, o que produz uma chave nova por tentativa e anula o mecanismo inteiro.
Chave por intenção, não por tentativa.
Onde mais isso aparece
Consumidor de fila é o mesmo problema com outra roupa. Toda fila entrega pelo menos uma vez, e “exactly once” é marketing. O consumidor precisa ser idempotente, com um identificador natural do evento e uma constraint única que impede o efeito duplicado.
Webhook idem. O provedor vai reenviar quando não receber 200 rápido, e vai reenviar em paralelo quando estiver com fila acumulada.
Sempre que existe rede entre a intenção e o efeito, existe duplicata. Idempotência é o que transforma isso em detalhe operacional em vez de incidente financeiro.
Falo mais sobre esse conjunto de padrões em o que é um core ledger, onde a chave única é o que impede um webhook reenviado de virar dinheiro inventado.