O caso interessante não é a repetição depois que a primeira terminou, esse é fácil. O caso que trava o checkout é a repetição enquanto a primeira ainda está em andamento, e ele é frequente justamente porque a lentidão é o que motiva o segundo clique. A implementação ingênua tranca a segunda requisição num bloqueio e a faz esperar pela primeira, o que transforma um problema de duplicidade num problema de latência: se a primeira demora quarenta segundos, a segunda demora quarenta segundos também, e agora existem duas conexões presas em vez de uma.
A saída é tratar a chave como um registro com estado próprio, gravado antes de qualquer efeito acontecer. A primeira requisição insere o registro com estado em andamento e segue para o processamento. A segunda tenta inserir, colide na restrição de unicidade, lê o registro existente e responde imediatamente com um código que diz ao cliente que o pedido já está sendo processado. Não há espera, não há conexão retida, e o cliente pode consultar o resultado depois em vez de segurar a linha.
ESPERAR PELO BLOQUEIO (o que trava o checkout)
req A --> adquire lock --> processa 40s ---------> responde 201
req B --> espera lock ..........................--> responde 201
40s de conexao presa sem fazer nada
cliente ve tela travada, tenta de novo, req C espera tambem
RESPONDER ESTADO (o que mantem o fluxo)
req A --> INSERT chave (in_progress) --> processa 40s --> UPDATE
| (completed +
| resposta)
v
req B --> INSERT falha na unicidade
--> le o registro: in_progress
--> responde 409 em 8ms com Retry-After: 2
cliente faz polling, nao segura conexao
req D (depois de A terminar)
--> INSERT falha na unicidade
--> le o registro: completed
--> devolve a MESMA resposta gravada, sem reprocessarO detalhe que costuma passar batido é que o registro da chave precisa ser gravado e confirmado antes de o efeito começar, e não junto dele. Se a inserção da chave acontece na mesma transação que cria a cobrança, ela só fica visível para outras conexões quando a transação inteira confirmar, e durante os quarenta segundos de processamento a segunda requisição não enxerga nada: ela insere com sucesso e cria a segunda cobrança. A separação em duas transações é o que torna a proteção efetiva no intervalo em que ela realmente importa.
// checkout/idempotency.mjs
// A chave vira um registro com estado, gravado e CONFIRMADO antes do efeito.
// Se a insercao acontecesse na mesma transacao do pagamento, ela so ficaria
// visivel no commit, e durante o processamento a segunda requisicao nao veria
// nada: inseriria com sucesso e criaria a segunda cobranca.
//
// CREATE TABLE idempotency_keys (
// key text NOT NULL,
// subject_id text NOT NULL, -- identidade AUTENTICADA, nao do corpo
// endpoint text NOT NULL, -- mesma chave em rotas diferentes e outra operacao
// request_hash text NOT NULL, -- impressao digital do corpo
// status text NOT NULL, -- in_progress | completed | failed
// response_code int,
// response_body jsonb,
// created_at timestamptz NOT NULL DEFAULT now(),
// PRIMARY KEY (key, subject_id, endpoint)
// );
import { createHash } from 'node:crypto';
const IN_PROGRESS_TTL_MS = 90_000; // acima do timeout do gateway
export const fingerprint = (body) =>
createHash('sha256')
// Chaves ordenadas: { a, b } e { b, a } sao o mesmo pedido e precisam
// produzir a mesma impressao digital, senao a retentativa vira conflito.
.update(JSON.stringify(body, Object.keys(body).sort()))
.digest('hex');
export class IdempotencyConflict extends Error {
constructor(status, payload) {
super('conflito de idempotencia');
this.name = 'IdempotencyConflict';
this.status = status;
this.payload = payload;
}
}
// Retorna { claimed: true } quando esta requisicao ganhou o direito de
// executar o efeito. Nos demais casos lanca com a resposta ja pronta.
export const claim = async (db, { key, subjectId, endpoint, requestHash }) => {
const inserted = await db.query(
`INSERT INTO idempotency_keys (key, subject_id, endpoint, request_hash, status)
VALUES ($1, $2, $3, $4, 'in_progress')
ON CONFLICT (key, subject_id, endpoint) DO NOTHING
RETURNING key`,
[key, subjectId, endpoint, requestHash],
);
if (inserted.rowCount === 1) return { claimed: true };
const [existing] = (
await db.query(
`SELECT request_hash, status, response_code, response_body, created_at
FROM idempotency_keys
WHERE key = $1 AND subject_id = $2 AND endpoint = $3`,
[key, subjectId, endpoint],
)
).rows;
// Mesma chave com corpo diferente e erro do cliente, nao repeticao.
// Devolver a resposta da primeira compra aqui confirmaria um pedido que
// o cliente nao fez.
if (existing.request_hash !== requestHash) {
throw new IdempotencyConflict(422, {
error: 'idempotency_key_reuse',
message: 'a chave ja foi usada com um corpo diferente',
});
}
if (existing.status === 'completed') {
// Mesma saida, sem repetir o efeito.
throw new IdempotencyConflict(existing.response_code, existing.response_body);
}
if (existing.status === 'failed') {
// Falha definitiva ja registrada: repetir produziria o mesmo erro.
throw new IdempotencyConflict(existing.response_code, existing.response_body);
}
const ageMs = Date.now() - new Date(existing.created_at).getTime();
// Registro preso em andamento alem do TTL: o processo que o criou morreu
// antes de finalizar. Liberar para uma nova tentativa em vez de deixar o
// cliente travado para sempre.
if (ageMs > IN_PROGRESS_TTL_MS) {
const retaken = await db.query(
`UPDATE idempotency_keys
SET created_at = now()
WHERE key = $1 AND subject_id = $2 AND endpoint = $3
AND status = 'in_progress' AND created_at = $4
RETURNING key`,
[key, subjectId, endpoint, existing.created_at],
);
if (retaken.rowCount === 1) return { claimed: true };
}
// Em andamento dentro do prazo: responder AGORA, sem esperar.
throw new IdempotencyConflict(409, {
error: 'in_progress',
message: 'o pedido ja esta sendo processado',
retryAfterSeconds: 2,
});
};
A comparação da impressão digital do corpo é a parte que quase sempre falta e a que tem a pior consequência quando falta. Sem ela, um cliente que reaproveita a chave por engano, seja porque o armazenamento da sessão não foi limpo ou porque o aplicativo móvel restaurou um estado antigo, recebe como resposta a confirmação de uma compra diferente da que acabou de pedir. Ele vê um pedido confirmado, o valor não bate, e o sistema não registrou erro nenhum porque do ponto de vista dele tudo funcionou.