Study guide
Idempotent by design, safe to retry.
How to make an operation safe to repeat: what idempotency is, how to implement it with keys and unique constraints, how to verify it and how to run it in production.
O que é idempotênciaWhat idempotency is
Uma operação é idempotente quando executá-la uma vez ou várias vezes com a mesma entrada deixa o sistema no mesmo estado final. Em matemática: f(f(x)) = f(x). No dia a dia: apertar o botão do elevador cinco vezes não chama cinco elevadores.An operation is idempotent when running it once or many times with the same input leaves the system in the same final state. In maths: f(f(x)) = f(x). In daily life: pressing the elevator button five times does not call five elevators.
Em APIs isso importa porque a rede falha no pior momento: o servidor processa, a resposta se perde, e o cliente não sabe se deu certo. A única reação segura é tentar de novo, e tentar de novo só é seguro se a operação for idempotente.In APIs this matters because the network fails at the worst moment: the server processes the request, the response is lost, and the client does not know whether it worked. The only safe reaction is to retry, and retrying is only safe if the operation is idempotent.
Idempotente não é o mesmo que seguroIdempotent is not the same as safe
| MétodoMethod | Seguro?Safe? | Idempotente?Idempotent? | ObservaçãoNote |
|---|---|---|---|
GET | simyes | simyes | só lêread-only |
PUT | nãono | simyes | substitui o recurso inteiro: repetir dá o mesmo resultadoreplaces the whole resource: repeating gives the same result |
DELETE | nãono | simyes | o 2º pode devolver 404, mas o estado final é o mesmothe 2nd may return 404, but the final state is the same |
POST | nãono | nãono | cria algo novo a cada chamada: precisa de chavecreates something new on each call: needs a key |
PATCH | nãono | dependedepends | {"name":"Ana"} é idempotente; {"balance":"+10"} não{"name":"Ana"} is idempotent; {"balance":"+10"} is not |
DELETE repetido pode responder 404 em vez de 204 e continuar idempotente. A RFC 9110 define as coisas assim: o efeito pretendido no servidor é o mesmo.Idempotent is about the final state, not the response. A repeated DELETE may answer 404 instead of 204 and still be idempotent. RFC 9110 defines it this way: the intended effect on the server is the same.Onde as repetições aparecemWhere repeats come from
Você não precisa de um cliente mal-educado para receber a mesma requisição duas vezes. Ela vem de comportamento normal:You do not need a badly behaved client to receive the same request twice. It comes from normal behaviour:
- Timeout e retry do cliente, do SDK, do proxy ou do load balancer.Timeouts and retries from the client, SDK, proxy or load balancer.
- Clique duplo ou botão "voltar e reenviar" no navegador.Double clicks or "back and resubmit" in the browser.
- Filas "at-least-once" (SQS, Kafka, RabbitMQ): a mensagem pode ser entregue mais de uma vez.At-least-once queues (SQS, Kafka, RabbitMQ): a message may be delivered more than once.
- Webhooks: Stripe, GitHub e outros reenviam o evento se você não responder 2xx a tempo.Webhooks: Stripe, GitHub and others resend the event if you do not answer 2xx in time.
- Jobs e deploys: um worker morre no meio e o job é reexecutado do começo.Jobs and deploys: a worker dies halfway and the job is re-run from the start.
Como "exatamente uma vez" não existe de verdade em sistemas distribuídos, o desenho prático é entrega pelo menos uma vez + processamento idempotente.Because "exactly once" does not really exist in distributed systems, the practical design is at-least-once delivery + idempotent processing.
Como implementarHow to implement
1. Prefira operações naturalmente idempotentes1. Prefer naturally idempotent operations
O jeito mais barato é desenhar a operação para que repetir não faça diferença: grave valores absolutos em vez de relativos, use PUT para substituir, e deixe o banco impor unicidade.The cheapest way is to design the operation so that repeating it makes no difference: write absolute values instead of relative ones, use PUT to replace, and let the database enforce uniqueness.
-- Naturally idempotent: the final state is the same no matter how many times it runs
INSERT INTO subscriptions (user_id, plan) VALUES (42, 'pro')
ON CONFLICT (user_id) DO UPDATE SET plan = EXCLUDED.plan;
UPDATE orders SET status = 'paid' WHERE id = 7; -- set an absolute value: idempotent
-- UPDATE accounts SET balance = balance + 10 ... -- relative change: NOT idempotent
Dê ao cliente o poder de escolher o ID: PUT /orders/{uuid} é idempotente por construção, enquanto POST /orders deixa o servidor gerar um ID novo a cada chamada.Let the client choose the ID: PUT /orders/{uuid} is idempotent by construction, whereas POST /orders lets the server generate a new ID on every call.
2. Idempotency-Key para o que não tem como ser idempotente (POST)2. Idempotency-Key for what cannot be idempotent (POST)
Para POST, o cliente gera uma chave única por intenção (um UUID) e a envia no cabeçalho Idempotency-Key. Em todo retry da mesma intenção, a mesma chave. O servidor guarda a chave junto com a resposta e, ao ver a chave de novo, devolve a resposta guardada em vez de executar outra vez. É o modelo da Stripe e do rascunho do IETF para o cabeçalho.For POST, the client generates one unique key per intent (a UUID) and sends it in the Idempotency-Key header. Every retry of the same intent reuses the same key. The server stores the key with the response and, on seeing the key again, returns the stored response instead of running it again. This is the model used by Stripe and the IETF draft for the header.
curl -X POST https://api.example.com/payments \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 7c1f3a52-0e4b-4d0a-9a3e-5b2c8d1f6e90" \
-H "Content-Type: application/json" \
-d '{"amount": 1990}'
Tabela de chaves (Postgres)Key table (Postgres)
CREATE TABLE idempotency_keys (
user_id bigint NOT NULL,
key text NOT NULL,
request_hash text NOT NULL, -- sha256 of method + path + body
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, key) -- the unique index does the real work
);
CREATE INDEX ON idempotency_keys (created_at); -- for the cleanup job
Middleware: reivindicar a chave, executar, salvarMiddleware: claim the key, run, save
import { createHash } from 'node:crypto';
import type { Pool } from 'pg';
export async function idempotent(
pool: Pool, userId: number, key: string, req: { method: string; path: string; body: unknown },
work: (tx: import('pg').PoolClient) => Promise<{ status: number; body: unknown }>,
) {
const hash = createHash('sha256')
.update(`${req.method} ${req.path} ${JSON.stringify(req.body)}`).digest('hex');
const tx = await pool.connect();
try {
await tx.query('BEGIN');
// 1) Claim the key. A concurrent duplicate BLOCKS here until we commit or roll back.
const claim = await tx.query(
`INSERT INTO idempotency_keys (user_id, key, request_hash)
VALUES ($1, $2, $3) ON CONFLICT (user_id, key) DO NOTHING`,
[userId, key, hash],
);
if (claim.rowCount === 0) {
// 2) Seen before: replay the saved response (or reject a reused key).
const { rows: [saved] } = await tx.query(
`SELECT request_hash, response_code, response_body
FROM idempotency_keys WHERE user_id = $1 AND key = $2`, [userId, key]);
await tx.query('COMMIT');
if (saved.request_hash !== hash)
return { status: 422, body: { error: 'Idempotency-Key reused with a different request' } };
return { status: saved.response_code, body: saved.response_body, replayed: true };
}
// 3) First time: do the work and save the response in the SAME transaction.
const result = await work(tx);
await tx.query(
`UPDATE idempotency_keys SET response_code = $3, response_body = $4
WHERE user_id = $1 AND key = $2`,
[userId, key, result.status, JSON.stringify(result.body)]);
await tx.query('COMMIT');
return result;
} catch (err) {
await tx.query('ROLLBACK'); // the key is released: the client can retry safely
throw err;
} finally {
tx.release();
}
}
app.post('/payments', async (req, res) => {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key header is required' });
const out = await idempotent(pool, req.user.id, key, req, async (tx) => {
const { rows: [payment] } = await tx.query(
'INSERT INTO payments (user_id, amount) VALUES ($1, $2) RETURNING id, amount',
[req.user.id, req.body.amount]);
return { status: 201, body: payment };
});
if (out.replayed) res.set('Idempotent-Replayed', 'true');
res.status(out.status).json(out.body);
});
| SituaçãoSituation | RespostaResponse |
|---|---|
| Chave novaNew key | executa e responde normalmente (201)runs and responds normally (201) |
| Mesma chave, mesmo corpoSame key, same body | devolve a resposta salva, com Idempotent-Replayed: truereturns the saved response, with Idempotent-Replayed: true |
| Mesma chave, corpo diferenteSame key, different body | 422: a chave foi reutilizada para outra coisa (bug do cliente)422: the key was reused for something else (client bug) |
| Sem a chaveNo key | 400 (ou aceite como opcional, mas então sem proteção)400 (or accept it as optional, but then without protection) |
| Falha antes do commitFailure before commit | rollback libera a chave: o retry executa de verdaderollback releases the key: the retry really runs |
E quando o efeito é externo (chamar um gateway de pagamento)?What if the effect is external (calling a payment gateway)?
Aí uma transação local não basta: você não consegue dar rollback na cobrança. Repasse a mesma chave ao provedor (a Stripe aceita Idempotency-Key nas próprias chamadas), e use um estado intermediário (processing → done): um duplicado que chega enquanto o primeiro roda recebe 409 com Retry-After.Here a local transaction is not enough: you cannot roll back a charge. Forward the same key to the provider (Stripe accepts Idempotency-Key on its own calls), and use an intermediate state (processing → done): a duplicate that arrives while the first one runs gets a 409 with Retry-After.
3. Consumidores de fila e webhooks3. Queue consumers and webhooks
Mesmo princípio, outra chave: o id da mensagem ou do evento. Registre o id numa tabela com restrição de unicidade, na mesma transação do efeito.Same principle, different key: the message or event id. Record the id in a table with a unique constraint, in the same transaction as the effect.
// Queue consumer or webhook handler: dedupe by the message/event id, in the same transaction
async function handle(event: { id: string; type: string; data: unknown }) {
const tx = await pool.connect();
try {
await tx.query('BEGIN');
const seen = await tx.query(
'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING', [event.id]);
if (seen.rowCount === 0) { await tx.query('COMMIT'); return; } // duplicate: ack and skip
await applyBusinessLogic(tx, event); // side effects in the DB
await tx.query('COMMIT');
} catch (err) {
await tx.query('ROLLBACK'); // not acked: it will be redelivered
throw err;
} finally { tx.release(); }
}
Como verificarHow to verify
Idempotência é uma propriedade que se testa, não que se presume. O teste central é sempre o mesmo: faça a operação N vezes e confira que o efeito colateral aconteceu uma vez só.Idempotency is a property you test, not one you assume. The core test is always the same: run the operation N times and check that the side effect happened only once.
Na mão, com curlBy hand, with curl
KEY=$(uuidgen)
for i in 1 2 3; do
curl -si -X POST localhost:3000/payments \
-H "Idempotency-Key: $KEY" -H "Content-Type: application/json" \
-d '{"amount":1990}' | grep -E "^HTTP|Idempotent-Replayed|\"id\""
done
# 1st: HTTP/1.1 201 2nd and 3rd: HTTP/1.1 201 + Idempotent-Replayed: true, same id
psql -c "SELECT count(*) FROM payments;" # must be 1
Teste automatizado (inclui concorrência)Automated test (including concurrency)
import { test, expect } from 'vitest';
const KEY = crypto.randomUUID();
const call = (body = { amount: 1990 }) =>
fetch('http://localhost:3000/payments', {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': KEY, authorization: 'Bearer test' },
body: JSON.stringify(body),
});
test('same key, 10 parallel calls: exactly one payment', async () => {
const responses = await Promise.all(Array.from({ length: 10 }, () => call()));
const bodies = await Promise.all(responses.map((r) => r.json()));
expect(new Set(bodies.map((b) => b.id)).size).toBe(1); // all got the same payment
expect(await countRows('payments WHERE user_id = 1')).toBe(1); // one side effect
});
test('same key, different body: 422', async () => {
const res = await call({ amount: 9999 });
expect(res.status).toBe(422);
});
O teste em paralelo é o que pega o bug mais comum: um SELECT para ver se a chave existe, seguido de um INSERT. Entre os dois, outra requisição passa. Só uma restrição de unicidade no banco fecha essa janela.The parallel test catches the most common bug: a SELECT to check whether the key exists, followed by an INSERT. Between the two, another request slips through. Only a unique constraint in the database closes that window.
Falhas injetadasInjected failures
- Derrubar a resposta: use um proxy de falhas (Toxiproxy) ou um
killlogo após oCOMMITe antes de responder. O retry deve devolver o resultado salvo, sem cobrar de novo.Drop the response: use a fault proxy (Toxiproxy) or akillright after theCOMMITand before responding. The retry must return the saved result, without charging again. - Derrubar antes do commit: o retry precisa executar de verdade (a chave não pode ficar "presa").Crash before the commit: the retry must really execute (the key must not get "stuck").
- Entrega duplicada: envie o mesmo evento duas vezes ao consumidor e confira o estado.Duplicate delivery: send the same event twice to the consumer and check the state.
- Propriedade: em testes de propriedade, para qualquer entrada
x,apply(apply(x))deve dar o mesmo estado queapply(x).Property: in property-based tests, for any inputx,apply(apply(x))must give the same state asapply(x).
Em produçãoIn production
| PontoPoint | PráticaPractice |
|---|---|
| Geração da chaveKey generation | o cliente gera (UUID v4) uma vez por intenção e a reutiliza em todo retry; nunca gere a chave a cada tentativathe client generates it (UUID v4) once per intent and reuses it on every retry; never generate the key per attempt |
| EscopoScope | chave única por usuário/tenant (PRIMARY KEY (user_id, key)), para um cliente não colidir nem espiar outrounique per user/tenant (PRIMARY KEY (user_id, key)), so one client cannot collide with or peek at another |
| Validade (TTL)Expiry (TTL) | guarde por 24h a 7 dias e apague com um job; o prazo deve ser maior que a janela de retry dos clienteskeep for 24h to 7 days and delete with a job; the window must exceed your clients' retry window |
| Onde guardarWhere to store | no mesmo banco do efeito, na mesma transação; Redis (SET NX) só serve de trava rápida, não de registro durávelin the same database as the effect, in the same transaction; Redis (SET NX) works only as a fast lock, not as a durable record |
| Corpo diferenteDifferent body | compare um hash do pedido e responda 422; senão uma chave reaproveitada executa a coisa errada em silênciocompare a hash of the request and answer 422; otherwise a reused key silently runs the wrong thing |
| Só guarde respostas definitivasStore only final answers | erros 5xx transitórios não devem "queimar" a chave; já 4xx de validação podem ser repetidos como estãotransient 5xx errors must not "burn" the key; validation 4xx may be replayed as they are |
| Retries do clienteClient retries | backoff exponencial com jitter e limite de tentativas, respeitando Retry-Afterexponential backoff with jitter and a retry cap, honouring Retry-After |
| ObservabilidadeObservability | métricas de replays, conflitos (409) e hashes divergentes (422); log da chave em todo requestmetrics for replays, conflicts (409) and mismatched hashes (422); log the key on every request |
| SegurançaSecurity | trate a chave como dado do cliente: valide tamanho (ex.: até 255 caracteres) e formato, e nunca a use como caminho de arquivotreat the key as client input: validate length (e.g. up to 255 chars) and format, and never use it as a file path |
-- job de limpeza (diário)
DELETE FROM idempotency_keys WHERE created_at < now() - interval '7 days';
Checklist antes de subirPre-release checklist
- Todo
POSTque cria dinheiro, pedido ou envio aceitaIdempotency-Key.EveryPOSTthat creates money, orders or shipments acceptsIdempotency-Key. - Existe restrição de unicidade no banco (não só um
SELECTantes).There is a unique constraint in the database (not just aSELECTbeforehand). - Chave, efeito e resposta salva na mesma transação (ou a chave é repassada ao provedor externo).Key, effect and saved response in the same transaction (or the key is forwarded to the external provider).
- Consumidores de fila e webhooks deduplicam pelo id do evento.Queue consumers and webhooks deduplicate by event id.
- Há teste de concorrência e teste de falha entre o commit e a resposta.There is a concurrency test and a failure test between the commit and the response.
- Existe TTL com job de limpeza e métricas de replay/409/422.There is a TTL with a cleanup job and replay/409/422 metrics.
POST sem chave que o cliente repete automaticamente (SDK, proxy, fila) é uma cobrança dupla esperando para acontecer. Se não dá para exigir a chave, desligue os retries automáticos desse endpoint.A keyless POST that the client retries automatically (SDK, proxy, queue) is a double charge waiting to happen. If you cannot require the key, turn off automatic retries for that endpoint.Exercício: pagamentos sem cobrança duplaExercise: payments without double charges
Pré-requisitos: Node.js, Docker e curl. Você vai subir um Postgres, criar as tabelas desta nota e provar que dez requisições simultâneas com a mesma chave geram um único pagamento.Prerequisites: Node.js, Docker and curl. You will start a Postgres, create this note's tables and prove that ten simultaneous requests with the same key produce a single payment.
docker run -d --name pg-idem -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17
export PGPASSWORD=dev
psql -h localhost -U postgres -c "CREATE TABLE payments (id bigserial PRIMARY KEY, user_id bigint, amount int);"
# create idempotency_keys with the SQL from the "How to implement" section
Monte a rota POST /payments (Express + pg) com a função idempotent acima e rode o servidor. Depois dispare as dez chamadas em paralelo:Build the POST /payments route (Express + pg) with the idempotent function above and run the server. Then fire the ten calls in parallel:
KEY=$(uuidgen)
seq 10 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/payments \
-H "Idempotency-Key: $KEY" -H "Content-Type: application/json" -d '{"amount":1990}' | sort | uniq -c
psql -h localhost -U postgres -c "SELECT count(*) FROM payments;"
Resultado esperado: as dez respostas são 201 (uma real, nove replays) e a contagem é 1. Agora quebre de propósito: troque o INSERT ... ON CONFLICT por um SELECT seguido de INSERT e repita. Você deve ver mais de um pagamento: é a corrida que a restrição de unicidade evita.Expected result: all ten responses are 201 (one real, nine replays) and the count is 1. Now break it on purpose: replace the INSERT ... ON CONFLICT with a SELECT followed by an INSERT and repeat. You should see more than one payment: that is the race the unique constraint prevents.
docker rm -f pg-idem # cleanup
Cola e revisãoCheat sheet and review
ColaCheat sheet
| Quero…I want… | UsoI use |
|---|---|
| repetir sem efeito duplicado em POSTrepeat a POST with no duplicate effect | Idempotency-Key + tabela com PRIMARY KEY + mesma transaçãoIdempotency-Key + table with PRIMARY KEY + same transaction |
| substituir um recursoreplace a resource | PUT /recurso/{id} com ID escolhido pelo clientePUT /resource/{id} with a client-chosen ID |
| criar ou atualizarcreate or update | INSERT ... ON CONFLICT DO UPDATEINSERT ... ON CONFLICT DO UPDATE |
| processar fila/webhook uma vezprocess a queue/webhook once | tabela processed_events(event_id UNIQUE) na mesma transaçãoprocessed_events(event_id UNIQUE) table in the same transaction |
| chave reutilizada com outro corpokey reused with another body | 422422 |
| duplicata ainda em execuçãoduplicate still running | 409 + Retry-After409 + Retry-After |
| provar que funcionaprove it works | N chamadas paralelas, uma linha no banco; falha entre commit e respostaN parallel calls, one row in the database; a failure between commit and response |
Perguntas de revisãoReview questions
- Por que
DELETEé idempotente mesmo que a segunda chamada devolva404?Why isDELETEidempotent even when the second call returns404? - Por que
POSTnão é idempotente e como aIdempotency-Keyresolve?Why isPOSTnot idempotent and how doesIdempotency-Keyfix it? - Por que um
SELECTseguido deINSERTnão basta para deduplicar?Why is aSELECTfollowed by anINSERTnot enough to deduplicate? - Por que a chave e o efeito precisam estar na mesma transação?Why must the key and the effect be in the same transaction?
- O que fazer quando o efeito é uma chamada a um provedor externo?What do you do when the effect is a call to an external provider?
- Qual deve ser o TTL das chaves e o que decide esse valor?What should the key TTL be and what decides it?