DevGuideTK
← Trilha Backend

Idempotência

Como tornar seguras as operações repetidas quando a rede falha no meio do pedido.

Os exemplos implementam POST /pagamentos com chave de idempotência sobre o PostgreSQL, na abordagem do nível Soluções: a chave e o pagamento gravados na mesma transação, com restrição única na chave. Cada versão foi executada com 20 pedidos simultâneos com a mesma chave e, depois, um pedido com a mesma chave e outro valor.

As tabelas

CREATE TABLE pagamentos (
  id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  pedido_id BIGINT NOT NULL,
  valor     INT NOT NULL
);

CREATE TABLE chaves_idempotencia (
  chave       TEXT PRIMARY KEY,   -- em produção: PRIMARY KEY (cliente_id, chave)
  hash_corpo  TEXT NOT NULL,
  status_http INT,
  resposta    JSONB,
  criada_em   TIMESTAMPTZ NOT NULL DEFAULT now()
);

A operação idempotente, em cada linguagem

import { createHash } from "node:crypto";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

type Corpo = { pedidoId: number; valor: number };

export async function criarPagamento(chave: string, corpo: Corpo) {
  const hash = createHash("sha256").update(JSON.stringify(corpo)).digest("hex");
  const conexao = await pool.connect();
  try {
    await conexao.query("BEGIN");

    // Se outra transação já gravou esta chave, este INSERT espera ela terminar
    const nova = await conexao.query(
      `INSERT INTO chaves_idempotencia (chave, hash_corpo) VALUES ($1, $2)
       ON CONFLICT (chave) DO NOTHING`,
      [chave, hash],
    );

    if (nova.rowCount === 0) {
      await conexao.query("ROLLBACK");
      // Usa a mesma conexão: pedir outra ao pool aqui pode travar tudo sob carga
      const { rows } = await conexao.query(
        "SELECT hash_corpo, status_http, resposta FROM chaves_idempotencia WHERE chave = $1",
        [chave],
      );
      if (rows[0].hash_corpo !== hash) {
        return { status: 422, corpo: { title: "Chave de idempotência usada com outro conteúdo" } };
      }
      return { status: rows[0].status_http, corpo: rows[0].resposta }; // a mesma resposta da primeira vez
    }

    const { rows } = await conexao.query(
      "INSERT INTO pagamentos (pedido_id, valor) VALUES ($1, $2) RETURNING id",
      [corpo.pedidoId, corpo.valor],
    );
    const resposta = { id: String(rows[0].id), ...corpo, status: "aprovado" };
    await conexao.query(
      "UPDATE chaves_idempotencia SET status_http = 201, resposta = $2 WHERE chave = $1",
      [chave, resposta],
    );

    await conexao.query("COMMIT");
    return { status: 201, corpo: resposta };
  } catch (erro) {
    await conexao.query("ROLLBACK");
    throw erro;
  } finally {
    conexao.release();
  }
}

O resultado, nas cinco linguagens

Pagamentos criados com 20 pedidos simultâneosRespostasMesma chave, valor diferente
TypeScript120 × 201, mesmo pagamento422
Python120 × 201, mesmo pagamento422
Go120 × 201, mesmo pagamento422
Java120 × 201, mesmo pagamento422
C#120 × 201, mesmo pagamento422
Versão ingênua (conferir e depois gravar)2020 pagamentos diferentesNão detecta

O que muda de uma linguagem para outra

Linhas afetadas pelo INSERTOnde ler a resposta guardada
TypeScriptrowCountNa mesma conexão, depois do ROLLBACK
Pythoncursor.rowcountNa mesma conexão, depois do bloco da transação
GoRowsAffected()db.QueryRowContext, depois de tx.Rollback(), que devolve a conexão ao pool
JavaRetorno de executeUpdate()Na mesma conexão, depois do rollback()
C#Retorno de ExecuteNonQueryAsync()Na mesma conexão, depois de descartar a transação

A coluna da direita registra a lição do teste. Na primeira versão em TypeScript, a leitura usava pool.query, que pede uma conexão nova enquanto a do pedido ainda está presa. Com 20 pedidos simultâneos e o pool padrão de 10 conexões, o teste travou: as 10 conexões ficaram esperando uma décima primeira. Em Go, o mesmo padrão funciona porque tx.Rollback() já devolve a conexão antes da consulta seguinte.

O hash do corpo depende da serialização. Em Python, sort_keys=True garante a mesma ordem dos campos; nas outras versões, o corpo é montado pelo próprio código, sempre na mesma ordem. Se o hash for calculado sobre o JSON recebido do cliente, normalize-o antes (ordem dos campos, espaços), ou dois pedidos iguais escritos de formas diferentes serão tratados como conteúdos diferentes.

A rota HTTP

app.post("/pagamentos", async (req, res) => {
  const chave = req.header("Idempotency-Key");
  if (!chave || chave.length > 255) {
    return res.status(400).type("application/problem+json").json({
      type: "https://api.loja.com.br/erros/chave-ausente",
      title: "O cabeçalho Idempotency-Key é obrigatório",
      status: 400,
    });
  }
  const { status, corpo } = await criarPagamento(chave, req.body);
  res.status(status).json(corpo);
});

O nome Idempotency-Key é o mais usado, e está sendo padronizado num rascunho do IETF. Exigir o cabeçalho nas rotas que cobram, em vez de deixá-lo opcional, evita que um cliente esqueça e só descubra quando cobrar duas vezes.

O cliente

import { randomUUID } from "node:crypto";

export async function pagar(pedidoId: number, valor: number) {
  const chave = randomUUID(); // uma chave por operação, gerada antes da primeira tentativa

  for (let tentativa = 1; tentativa <= 4; tentativa++) {
    try {
      const resposta = await fetch("https://api.loja.com.br/pagamentos", {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": chave },
        body: JSON.stringify({ pedidoId, valor }),
        signal: AbortSignal.timeout(5000),
      });
      if (resposta.status === 409 || resposta.status >= 500) throw new Error(`HTTP ${resposta.status}`);
      return resposta; // sucesso ou erro definitivo do cliente: não repetir
    } catch (erro) {
      if (tentativa === 4) throw erro;
      await new Promise((r) => setTimeout(r, 200 * 2 ** tentativa)); // mesma chave em todas
    }
  }
}

A chave nasce fora do laço de tentativas. Gerar uma chave nova a cada tentativa, um erro comum, desliga a proteção inteira.

Lista de revisão

Servidor

  • Toda rota que cria ou cobra exige uma chave de idempotência?
  • A chave é reservada com restrição única, na mesma transação do efeito?
  • A resposta é guardada e devolvida igual nas repetições?
  • A mesma chave com outro conteúdo é recusada?
  • A chave é única por cliente, com prazo de validade e limpeza?
  • Efeitos externos recebem uma chave derivada da original?

Concorrência

  • Nenhuma parte do código pede uma conexão nova ao pool enquanto segura outra?
  • O comportamento com pedidos simultâneos foi testado, e não só com pedidos em sequência?

Cliente

  • A chave é gerada uma vez por operação, antes da primeira tentativa?
  • As novas tentativas usam a mesma chave, com intervalo crescente?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar e testar sob concorrência

    Quero implementar chaves de idempotência na minha linguagem e provar que funcionam com pedidos simultâneos.
    
    Pergunte qual linguagem, banco e driver eu uso.
    
    Vamos em etapas:
    1. Criar as tabelas de pagamentos e de chaves.
    2. Implementar a versão ingênua, que confere e depois grava, e um teste que dispara 20 pedidos simultâneos com a mesma chave. Contar os pagamentos.
    3. Implementar a versão com restrição única e INSERT com ON CONFLICT na mesma transação, e repetir o teste.
    4. Testar a mesma chave com conteúdo diferente.
    5. Testar com o pool de conexões menor que o número de pedidos simultâneos, para garantir que nada trava.
    
    Em cada etapa, peça que eu preveja o resultado antes de rodar.
  • Middleware de idempotência

    Quero criar um middleware que aplique chaves de idempotência às rotas da minha API, sem repetir o código em cada uma.
    
    Pergunte qual linguagem e framework eu uso.
    
    Explique, com código:
    1. Como o middleware lê o cabeçalho Idempotency-Key e identifica o cliente.
    2. Como reservar a chave e guardar a resposta da rota.
    3. As limitações: quando o middleware não consegue garantir que o efeito e a chave foram gravados juntos.
    4. Quando é melhor implementar a idempotência dentro da própria rota, na transação do efeito.
  • Revisar uma rota que cria ou cobra

    Vou colar abaixo o código de uma rota que cria um recurso ou faz uma cobrança. Revise com foco em repetições.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando a linha:
    1. A rota aceita ou exige uma chave de idempotência?
    2. A reserva da chave é atômica, com restrição única, ou é "conferir e depois gravar"?
    3. A chave e o efeito ficam na mesma transação?
    4. A resposta é guardada e devolvida nas repetições?
    5. A mesma chave com conteúdo diferente é recusada?
    6. Chamadas a serviços externos repassam uma chave de idempotência?
    7. Há algum ponto que pede uma segunda conexão ao pool enquanto segura outra?
    
    Para cada problema, descreva o cenário que causa duplicação ou travamento, e mostre a correção.
    
    Código:
    [cole aqui]