Idempotência

A rede não colabora. O cliente envia a transferência, perde o sinal antes da resposta e tenta de novo. Sem uma chave de idempotência, ele paga duas vezes e descobre no extrato.

Como usar

Toda chamada que move dinheiro exige o cabeçalho Idempotency-Key, com 8 a 128 caracteres, único por operação. Um UUID v4 serve.

cabeçalho
Idempotency-Key: 6f1e2a94-7c3d-4b58-9e21-0a8c5d3f7b62

Sem ele, a resposta é 400 antes de qualquer efeito. Isso é deliberado: uma API de pagamento que aceita chamada sem chave de idempotência está oferecendo o toque duplo como recurso.

O que acontece ao repetir

SituaçãoResposta
Mesma chave, mesmo corpo, operação já concluídaA resposta original, idêntica, com o cabeçalho Idempotent-Replay: true. O dinheiro saiu uma vez só.
Mesma chave, mesmo corpo, operação ainda em curso409 IDEMPOTENCY_IN_FLIGHT, com Retry-After. Repita em alguns segundos e receba a resposta da primeira.
Mesma chave, corpo diferente409 IDEMPOTENCY_KEY_REUSED. Devolver a resposta antiga seria mentir sobre uma operação que não aconteceu; executar de novo seria cobrar duas vezes.
A operação falhou com erro do servidorA chave é liberada. Um 500 pode ter acontecido antes de qualquer efeito, e travar a chave para sempre impediria a tentativa que resolveria o problema.

O escopo da chave

A chave vale por conta e por rota. A mesma chave usada por dois clientes diferentes são duas operações diferentes, e uma não devolve a resposta da outra. A exceção é a cobrança estática: POST /v1/pix/brcode e POST /v1/pix/qrcodes registram pelo mesmo caminho e dividem o espaço de chaves da conta.

A chave é lembrada por 90 dias a partir da primeira requisição. Repetir depois disso conta como operação nova.

Duas requisições ao mesmo tempo

A chave é reservada antes de a operação rodar. Consultar primeiro e gravar depois deixaria uma janela entre as duas em que ninguém tem a chave: duas requisições simultâneas leriam “não existe” e as duas transfeririam. E é exatamente o toque duplo — concorrente, e não sequencial — que a proteção existe para cobrir.

Repetição na prática

TypeScript
async function transferir(pedidoId: string, chavePix: string, centavos: number) {
  // A chave nasce do pedido, e não da tentativa: repetir a mesma
  // transferência precisa produzir a mesma chave.
  const idempotencia = `pedido-${pedidoId}`

  for (let tentativa = 0; tentativa < 3; tentativa++) {
    const resposta = await fetch(`${base}/v1/pix/transfers`, {
      method: 'POST',
      headers: {
        'X-API-Key': chave,
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencia,
      },
      body: JSON.stringify({ key: chavePix, amountCents: centavos }),
    })

    if (resposta.ok) return resposta.json()

    const erro = await resposta.json()
    // A primeira ainda está rodando: esperar é o certo, e a resposta que vem
    // depois é a mesma que teria vindo se tivéssemos esperado desde o início.
    if (erro.code === 'IDEMPOTENCY_IN_FLIGHT') {
      const espera = Number(resposta.headers.get('Retry-After') ?? 2)
      await new Promise((r) => setTimeout(r, espera * 1000))
      continue
    }
    throw new Error(`${erro.code}: ${erro.message}`)
  }
  throw new Error('a transferência não concluiu a tempo')
}