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.

clienteclient API bancodatabase Sem chave: o retry cobra duas vezesWithout a key: the retry charges twice POST /charge cobra #1charge #1 timeout: a resposta se perdeutimeout: the response was lost POST /charge (retry) cobra #2 ✗charge #2 ✗ Com chave: o retry devolve o resultado salvoWith a key: the retry returns the saved result POST + Key: k1 cobra #1charge #1 retry + Key: k1 k1 já visto: devolve o #1k1 seen: returns #1
Sem chave, o timeout seguido de retry duplica o efeito; com chave, o servidor reconhece o pedido repetido.Without a key, a timeout followed by a retry duplicates the effect; with a key, the server recognises the repeated request.

Idempotente não é o mesmo que seguroIdempotent is not the same as safe

MétodoMethodSeguro?Safe?Idempotente?Idempotent?ObservaçãoNote
GETsimyessimyessó lêread-only
PUTnãonosimyessubstitui o recurso inteiro: repetir dá o mesmo resultadoreplaces the whole resource: repeating gives the same result
DELETEnãonosimyeso 2º pode devolver 404, mas o estado final é o mesmothe 2nd may return 404, but the final state is the same
POSTnãononãonocria algo novo a cada chamada: precisa de chavecreates something new on each call: needs a key
PATCHnãonodependedepends{"name":"Ana"} é idempotente; {"balance":"+10"} não{"name":"Ana"} is idempotent; {"balance":"+10"} is not
Idempotente fala do estado final, não da resposta. Um 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

requisiçãorequest INSERT da chaveINSERT the key (índice único)(unique index) novo: executa e salvanew: run and save visto: devolve o salvoseen: replay saved outro hash: 422other hash: 422 Tudo (INSERT da chave + trabalho + resposta salva) acontece numa transação.Everything (key INSERT + work + saved response) happens in one transaction. Uma duplicata concorrente espera no INSERT e, depois do commit, só lê o resultado.A concurrent duplicate waits on the INSERT and, after the commit, just reads the result.
A chave é reivindicada com um INSERT; o índice único decide quem executa e quem só repete a resposta.The key is claimed with an INSERT; the unique index decides who runs and who just replays the response.
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çãoSituationRespostaResponse
Chave novaNew keyexecuta e responde normalmente (201)runs and responds normally (201)
Mesma chave, mesmo corpoSame key, same bodydevolve a resposta salva, com Idempotent-Replayed: truereturns the saved response, with Idempotent-Replayed: true
Mesma chave, corpo diferenteSame key, different body422: a chave foi reutilizada para outra coisa (bug do cliente)422: the key was reused for something else (client bug)
Sem a chaveNo key400 (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 commitrollback libera a chave: o retry executa de verdaderollback releases the key: the retry really runs
O trabalho e a gravação da chave precisam estar na mesma transação. Se a chave fosse salva em outro lugar (ou em outro banco, como Redis), um crash entre as duas gravações deixaria o efeito feito sem registro (duplica no retry) ou o registro feito sem efeito (perde a operação).The work and the key write must be in the same transaction. If the key were saved elsewhere (or in another store like Redis), a crash between the two writes would leave the effect done with no record (duplicates on retry) or the record without the effect (loses the operation).

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 kill logo após o COMMIT e antes de responder. O retry deve devolver o resultado salvo, sem cobrar de novo.Drop the response: use a fault proxy (Toxiproxy) or a kill right after the COMMIT and 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 que apply(x).Property: in property-based tests, for any input x, apply(apply(x)) must give the same state as apply(x).

Em produçãoIn production

PontoPointPráticaPractice
Geração da chaveKey generationo 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
EscopoScopechave ú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 storeno 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 bodycompare 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 answerserros 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 retriesbackoff exponencial com jitter e limite de tentativas, respeitando Retry-Afterexponential backoff with jitter and a retry cap, honouring Retry-After
ObservabilidadeObservabilitymé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çaSecuritytrate 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 POST que cria dinheiro, pedido ou envio aceita Idempotency-Key.Every POST that creates money, orders or shipments accepts Idempotency-Key.
  • Existe restrição de unicidade no banco (não só um SELECT antes).There is a unique constraint in the database (not just a SELECT beforehand).
  • 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.
Um 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 effectIdempotency-Key + tabela com PRIMARY KEY + mesma transaçãoIdempotency-Key + table with PRIMARY KEY + same transaction
substituir um recursoreplace a resourcePUT /recurso/{id} com ID escolhido pelo clientePUT /resource/{id} with a client-chosen ID
criar ou atualizarcreate or updateINSERT ... ON CONFLICT DO UPDATEINSERT ... ON CONFLICT DO UPDATE
processar fila/webhook uma vezprocess a queue/webhook oncetabela 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 body422422
duplicata ainda em execuçãoduplicate still running409 + Retry-After409 + Retry-After
provar que funcionaprove it worksN 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 devolva 404?Why is DELETE idempotent even when the second call returns 404?
  • Por que POST não é idempotente e como a Idempotency-Key resolve?Why is POST not idempotent and how does Idempotency-Key fix it?
  • Por que um SELECT seguido de INSERT não basta para deduplicar?Why is a SELECT followed by an INSERT not 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?