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();
}
}import hashlib
import json
import psycopg
from psycopg.types.json import Jsonb
def criar_pagamento(conexao: psycopg.Connection, chave: str, corpo: dict) -> tuple[int, dict]:
hash_corpo = hashlib.sha256(json.dumps(corpo, sort_keys=True).encode()).hexdigest()
with conexao.transaction():
# Se outra transação já gravou esta chave, este INSERT espera ela terminar
nova = conexao.execute(
"""INSERT INTO chaves_idempotencia (chave, hash_corpo) VALUES (%s, %s)
ON CONFLICT (chave) DO NOTHING""",
(chave, hash_corpo),
).rowcount
if nova:
pagamento_id = conexao.execute(
"INSERT INTO pagamentos (pedido_id, valor) VALUES (%s, %s) RETURNING id",
(corpo["pedidoId"], corpo["valor"]),
).fetchone()[0]
resposta = {"id": str(pagamento_id), **corpo, "status": "aprovado"}
conexao.execute(
"UPDATE chaves_idempotencia SET status_http = 201, resposta = %s WHERE chave = %s",
(Jsonb(resposta), chave),
)
return 201, resposta
# A chave já existia: devolve a resposta guardada
hash_salvo, status, resposta = conexao.execute(
"SELECT hash_corpo, status_http, resposta FROM chaves_idempotencia WHERE chave = %s",
(chave,),
).fetchone()
if hash_salvo != hash_corpo:
return 422, {"title": "Chave de idempotência usada com outro conteúdo"}
return status, respostapackage pagamentos
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"strconv"
)
type Corpo struct {
PedidoID int64 `json:"pedidoId"`
Valor int `json:"valor"`
}
func CriarPagamento(ctx context.Context, db *sql.DB, chave string, corpo Corpo) (int, json.RawMessage, error) {
bruto, _ := json.Marshal(corpo)
soma := sha256.Sum256(bruto)
hash := hex.EncodeToString(soma[:])
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, nil, err
}
defer tx.Rollback()
// Se outra transação já gravou esta chave, este INSERT espera ela terminar
res, err := tx.ExecContext(ctx,
`INSERT INTO chaves_idempotencia (chave, hash_corpo) VALUES ($1, $2)
ON CONFLICT (chave) DO NOTHING`, chave, hash)
if err != nil {
return 0, nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
tx.Rollback()
var hashSalvo string
var status int
var resposta json.RawMessage
err := db.QueryRowContext(ctx,
"SELECT hash_corpo, status_http, resposta FROM chaves_idempotencia WHERE chave = $1",
chave).Scan(&hashSalvo, &status, &resposta)
if err != nil {
return 0, nil, err
}
if hashSalvo != hash {
return 422, json.RawMessage(`{"title":"Chave de idempotência usada com outro conteúdo"}`), nil
}
return status, resposta, nil
}
var id int64
err = tx.QueryRowContext(ctx,
"INSERT INTO pagamentos (pedido_id, valor) VALUES ($1, $2) RETURNING id",
corpo.PedidoID, corpo.Valor).Scan(&id)
if err != nil {
return 0, nil, err
}
resposta, _ := json.Marshal(map[string]any{
"id": strconv.FormatInt(id, 10), "pedidoId": corpo.PedidoID, "valor": corpo.Valor, "status": "aprovado",
})
_, err = tx.ExecContext(ctx,
"UPDATE chaves_idempotencia SET status_http = 201, resposta = $2 WHERE chave = $1",
chave, string(resposta))
if err != nil {
return 0, nil, err
}
return 201, resposta, tx.Commit()
}import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.util.HexFormat;
import javax.sql.DataSource;
public class Pagamentos {
public record Resultado(int status, String corpo) {}
public static Resultado criar(DataSource dados, String chave, long pedidoId, int valor) throws Exception {
var corpo = "{\"pedidoId\":" + pedidoId + ",\"valor\":" + valor + "}";
var hash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(corpo.getBytes(StandardCharsets.UTF_8)));
try (Connection conexao = dados.getConnection()) {
conexao.setAutoCommit(false);
int nova;
// Se outra transação já gravou esta chave, este INSERT espera ela terminar
try (var insercao = conexao.prepareStatement(
"INSERT INTO chaves_idempotencia (chave, hash_corpo) VALUES (?, ?) ON CONFLICT (chave) DO NOTHING")) {
insercao.setString(1, chave);
insercao.setString(2, hash);
nova = insercao.executeUpdate();
}
if (nova == 0) {
conexao.rollback();
conexao.setAutoCommit(true);
try (var consulta = conexao.prepareStatement(
"SELECT hash_corpo, status_http, resposta::text FROM chaves_idempotencia WHERE chave = ?")) {
consulta.setString(1, chave);
try (var linha = consulta.executeQuery()) {
linha.next();
if (!linha.getString(1).equals(hash)) {
return new Resultado(422, "{\"title\":\"Chave de idempotência usada com outro conteúdo\"}");
}
return new Resultado(linha.getInt(2), linha.getString(3));
}
}
}
long id;
try (var pagamento = conexao.prepareStatement(
"INSERT INTO pagamentos (pedido_id, valor) VALUES (?, ?) RETURNING id")) {
pagamento.setLong(1, pedidoId);
pagamento.setInt(2, valor);
try (var linha = pagamento.executeQuery()) {
linha.next();
id = linha.getLong(1);
}
}
var resposta = "{\"id\":\"" + id + "\",\"pedidoId\":" + pedidoId
+ ",\"valor\":" + valor + ",\"status\":\"aprovado\"}";
try (var atualizacao = conexao.prepareStatement(
"UPDATE chaves_idempotencia SET status_http = 201, resposta = ?::jsonb WHERE chave = ?")) {
atualizacao.setString(1, resposta);
atualizacao.setString(2, chave);
atualizacao.executeUpdate();
}
conexao.commit();
return new Resultado(201, resposta);
}
}
}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Npgsql;
public record Resultado(int Status, string Corpo);
public class Pagamentos(NpgsqlDataSource dados)
{
public async Task<Resultado> Criar(string chave, long pedidoId, int valor)
{
var corpo = JsonSerializer.Serialize(new { pedidoId, valor });
var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(corpo)));
await using var conexao = await dados.OpenConnectionAsync();
await using (var transacao = await conexao.BeginTransactionAsync())
{
// Se outra transação já gravou esta chave, este INSERT espera ela terminar
await using var insercao = new NpgsqlCommand(
"INSERT INTO chaves_idempotencia (chave, hash_corpo) VALUES ($1, $2) ON CONFLICT (chave) DO NOTHING",
conexao, transacao) { Parameters = { new() { Value = chave }, new() { Value = hash } } };
if (await insercao.ExecuteNonQueryAsync() == 1)
{
await using var pagamento = new NpgsqlCommand(
"INSERT INTO pagamentos (pedido_id, valor) VALUES ($1, $2) RETURNING id",
conexao, transacao) { Parameters = { new() { Value = pedidoId }, new() { Value = valor } } };
var id = (long)(await pagamento.ExecuteScalarAsync())!;
var resposta = JsonSerializer.Serialize(new { id = id.ToString(), pedidoId, valor, status = "aprovado" });
await using var atualizacao = new NpgsqlCommand(
"UPDATE chaves_idempotencia SET status_http = 201, resposta = $2::jsonb WHERE chave = $1",
conexao, transacao) { Parameters = { new() { Value = chave }, new() { Value = resposta } } };
await atualizacao.ExecuteNonQueryAsync();
await transacao.CommitAsync();
return new Resultado(201, resposta);
}
} // sem Commit, a transação é desfeita aqui
// A chave já existia: devolve a resposta guardada
await using var consulta = new NpgsqlCommand(
"SELECT hash_corpo, status_http, resposta::text FROM chaves_idempotencia WHERE chave = $1",
conexao) { Parameters = { new() { Value = chave } } };
await using var linha = await consulta.ExecuteReaderAsync();
await linha.ReadAsync();
if (linha.GetString(0) != hash)
return new Resultado(422, """{"title":"Chave de idempotência usada com outro conteúdo"}""");
return new Resultado(linha.GetInt32(1), linha.GetString(2));
}
}O resultado, nas cinco linguagens
| Pagamentos criados com 20 pedidos simultâneos | Respostas | Mesma chave, valor diferente | |
|---|---|---|---|
| TypeScript | 1 | 20 × 201, mesmo pagamento | 422 |
| Python | 1 | 20 × 201, mesmo pagamento | 422 |
| Go | 1 | 20 × 201, mesmo pagamento | 422 |
| Java | 1 | 20 × 201, mesmo pagamento | 422 |
| C# | 1 | 20 × 201, mesmo pagamento | 422 |
| Versão ingênua (conferir e depois gravar) | 20 | 20 pagamentos diferentes | Não detecta |
O que muda de uma linguagem para outra
Linhas afetadas pelo INSERT | Onde ler a resposta guardada | |
|---|---|---|
| TypeScript | rowCount | Na mesma conexão, depois do ROLLBACK |
| Python | cursor.rowcount | Na mesma conexão, depois do bloco da transação |
| Go | RowsAffected() | db.QueryRowContext, depois de tx.Rollback(), que devolve a conexão ao pool |
| Java | Retorno 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]