Pular para o conteúdo
ZYNQORALABS

Chaves de idempotência em APIs: como evitar cobranças duplicadas

Retentativas são inevitáveis em redes reais. Como implementar chaves de idempotência em uma API: armazenamento, concorrência, validação de payload e expiração.

Por ZynqoraLabs · · 3 min de leitura

CompartilharLinkedInXWhatsApp

Um cliente envia POST /payments. A requisição chega, o pagamento é processado, mas a resposta se perde no caminho: timeout no balanceador, queda de Wi-Fi, um proxy reiniciando. Do ponto de vista do cliente, o resultado é desconhecido. Ele tenta de novo. Sem cuidado, o pagamento é cobrado duas vezes.

Retentativas não são um defeito do cliente: são a resposta correta a falhas de rede. O que precisa mudar é o servidor, que deve reconhecer quando está recebendo a mesma operação de novo.

Métodos idempotentes e não idempotentes

No HTTP, GET, PUT e DELETE são definidos como idempotentes: repetir a requisição deixa o servidor no mesmo estado. POST não é. Criar um recurso, iniciar um pagamento ou enviar uma mensagem são operações que, repetidas, produzem efeitos repetidos.

A solução amplamente adotada é a chave de idempotência: o cliente gera um identificador único por operação e o envia em um cabeçalho. Se o servidor já processou aquela chave, devolve o resultado anterior em vez de executar de novo. O uso do cabeçalho Idempotency-Key é descrito em um rascunho (Internet-Draft) do IETF e segue o padrão que várias APIs de pagamento já usam.

requisição
POST /payments HTTP/1.1
Idempotency-Key: 6d1f6a0e-4c2b-4b8e-9f8a-2d3e1c0b7a55
Content-Type: application/json
 
{"amount": "150.00", "currency": "BRL", "customer_id": "cus_812"}

O que o servidor precisa guardar

Para cada chave, o servidor registra:

  • a chave e o cliente a que pertence (chaves são únicas por cliente, não globalmente);
  • uma impressão digital do payload, para detectar a mesma chave usada com dados diferentes;
  • o estado da operação: em andamento ou concluída;
  • a resposta produzida, com status e corpo, para devolver em repetições.
idempotency.sql
CREATE TABLE idempotency_keys (
  client_id       text        NOT NULL,
  key             text        NOT NULL,
  request_hash    bytea       NOT NULL,
  status          text        NOT NULL CHECK (status IN ('processing', 'completed')),
  response_code   integer,
  response_body   jsonb,
  created_at      timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (client_id, key)
);

O fluxo, incluindo a concorrência

O caso difícil não é a repetição horas depois. É a repetição que chega enquanto a primeira ainda está sendo processada. A restrição de chave primária resolve isso: só uma requisição consegue inserir a chave.

idempotency.ts
export async function withIdempotency(req: Request, clientId: string, handler: () => Promise<ApiResponse>) {
  const key = req.headers.get("Idempotency-Key");
  if (!key) return handler(); // ou exija a chave em rotas de cobrança
  const hash = sha256(canonicalJson(await req.clone().json()));
 
  const inserted = await db.query(
    `INSERT INTO idempotency_keys (client_id, key, request_hash, status)
     VALUES ($1, $2, $3, 'processing')
     ON CONFLICT DO NOTHING RETURNING key`,
    [clientId, key, hash],
  );
 
  if (inserted.rowCount === 0) {
    const existing = await db.one(`SELECT * FROM idempotency_keys WHERE client_id = $1 AND key = $2`, [clientId, key]);
    if (!existing.request_hash.equals(hash)) return problem(422, "Idempotency-Key reused with a different payload");
    if (existing.status === "processing") return problem(409, "A request with this key is still in progress");
    return replay(existing.response_code, existing.response_body);
  }
 
  const response = await handler();
  await db.query(
    `UPDATE idempotency_keys SET status = 'completed', response_code = $3, response_body = $4
      WHERE client_id = $1 AND key = $2`,
    [clientId, key, response.status, response.body],
  );
  return response;
}

Três respostas merecem atenção:

  • 409 quando a mesma chave ainda está em processamento. O cliente deve aguardar e tentar de novo.
  • 422 quando a chave foi reutilizada com outro conteúdo. Isso indica um bug no cliente e nunca deve ser tratado como repetição.
  • Repetição da resposta original, incluindo erros de validação, quando a operação já foi concluída.

O ponto mais sutil: o efeito e a chave

No exemplo acima, se o processo cair depois de cobrar o cartão e antes de marcar a chave como concluída, a chave fica presa em processing. Há duas formas de lidar com isso:

  1. Mesma transação: quando o efeito é uma escrita no seu próprio banco, grave o resultado e conclua a chave na mesma transação. Ou as duas coisas acontecem, ou nenhuma.
  2. Propagar a chave: quando o efeito é uma chamada externa, como um provedor de pagamento, envie a ele uma chave de idempotência derivada da sua. Assim, ao reprocessar uma chave presa, a chamada externa também é deduplicada.

Defina também um tempo máximo em processing, após o qual a operação pode ser retomada com segurança, porque o efeito externo é idempotente.

Expiração e escopo

Chaves não precisam viver para sempre. Um período de 24 horas a alguns dias cobre retentativas razoáveis; depois disso, os registros podem ser removidos. Documente esse prazo na API.

Aplique chaves de idempotência às operações que criam efeitos irreversíveis ou caros: pagamentos, pedidos, transferências, envio de mensagens. Em operações naturalmente idempotentes, como um PUT que substitui um recurso, elas são desnecessárias.

Resumo

Chaves de idempotência transformam retentativas, que são inevitáveis, em operações seguras. O essencial é guardar a chave por cliente com uma impressão digital do payload, usar a restrição do banco para resolver concorrência, devolver a resposta original em repetições e garantir que o efeito e o registro da chave não possam divergir.