Blog

Idempotência: a chave que salva sua API de cobrar duas vezes.

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.

← Voltar pro blog