A primeira requisição da API passou no teste. Depois o cliente perdeu a resposta, tentou de novo e criou o mesmo pedido duas vezes. Esse cenário aparece quando há timeout, refresh, duplo clique ou retry automático. O caminho feliz continua verde, mas a operação que realmente precisava de proteção ficou sem prova.
Idempotência não significa que toda chamada tem a mesma resposta. Significa que repetir a mesma intenção não deve repetir o efeito. Para testar isso, você precisa contar o efeito observável e exercitar as requisições repetidas, concorrentes e incompatíveis. O teste de webhooks com duplicatas e retries ajuda quando a origem é um provedor; aqui, a fronteira é uma API que você controla.

Resposta curta
- O primeiro pedido deve executar o efeito uma vez.
- O mesmo pedido depois da conclusão deve devolver o resultado guardado, sem executar de novo.
- Uma duplicata que chega enquanto a primeira ainda está em andamento precisa de um resultado explícito, como
409.- A mesma chave com outro payload deve ser rejeitada, como
422, quando o contrato compara a impressão do pedido.
O que um teste de idempotência precisa provar?
Uma requisição POST pode produzir um novo efeito a cada chegada. O cabeçalho
Idempotency-Key permite que o servidor reconheça uma tentativa repetida, mas o
contrato pertence ao endpoint. A referência da MDN sobre o cabeçalho Idempotency-Key separa a responsabilidade do cliente, que reutiliza a chave no retry, da responsabilidade do servidor, que documenta a regra.
O teste deve seguir a chamada até o ponto em que o efeito acontece. Verificar apenas que a segunda consulta encontrou uma chave é insuficiente. Um bug pode detectar a duplicata e, ainda assim, deixar o handler continuar para uma etapa de pós-processamento. Uma issue recente do Mastodon mostra exatamente esse tipo de falha: a duplicata foi reconhecida, mas o fluxo posterior produziu um erro.
| Caso | O que simular | O que verificar |
|---|---|---|
| Primeira chamada | chave nova e payload válido | efeito executado e resposta de sucesso |
| Replay | mesma chave e mesmo payload depois da conclusão | mesma resposta, efeito sem nova execução |
| Concorrência | mesma chave enquanto a primeira aguarda | conflito ou resposta em espera, conforme o contrato |
| Chave reutilizada | mesma chave com payload diferente | rejeição antes do efeito |
| Falha recuperável | efeito falha antes de terminar | regra documentada para liberar ou guardar a chave |
Essa matriz evita misturar duas perguntas. A primeira é se o endpoint reconhece a mesma intenção. A segunda é se a proteção alcança o efeito, o armazenamento da resposta e os caminhos de erro.
Como montar um handler pequeno para testar?
O exemplo abaixo usa um Map para deixar o contrato visível. Ele não é uma solução
de produção para múltiplos processos. O objetivo é criar uma fixture determinística
que conte quantas vezes a operação real foi chamada.
class IdempotencyStore {
#entries = new Map();
begin(key, fingerprint) {
const existing = this.#entries.get(key);
if (!existing) {
this.#entries.set(key, { fingerprint, state: 'running' });
return { kind: 'new' };
}
if (existing.fingerprint !== fingerprint) {
return { kind: 'payload-mismatch' };
}
if (existing.state === 'running') return { kind: 'in-flight' };
return { kind: 'replay', response: existing.response };
}
complete(key, response) {
const entry = this.#entries.get(key);
if (!entry) throw new Error('missing idempotency entry');
this.#entries.set(key, { ...entry, state: 'complete', response });
}
}
function createHandler(store, perform) {
return async function handle({ key, body }) {
if (!key) return { status: 400, body: { error: 'missing_key' } };
const fingerprint = JSON.stringify(body);
const decision = store.begin(key, fingerprint);
if (decision.kind === 'payload-mismatch') {
return { status: 422, body: { error: 'key_reused_with_other_payload' } };
}
if (decision.kind === 'in-flight') {
return { status: 409, body: { error: 'request_in_progress' } };
}
if (decision.kind === 'replay') return decision.response;
const result = await perform(body);
const response = { status: 201, body: result };
store.complete(key, response);
return response;
};
}
Há quatro decisões importantes nesse trecho. A entrada grava running antes de
chamar o efeito. O replay devolve a resposta já concluída. O payload diferente não
passa pelo efeito. E o efeito só vira complete depois que a resposta está pronta.
O guia de requisições idempotentes da Stripe descreve uma fronteira semelhante: resultados são guardados depois que a execução do endpoint começa, enquanto conflitos concorrentes não precisam guardar um resultado idempotente.
O JSON.stringify serve apenas como impressão simples para a fixture. Em uma API
real, a impressão precisa ser estável e seguir o contrato do endpoint. Ordem de
propriedades, campos ignorados, normalização e limites do payload não podem ficar
implícitos.
Como testar o replay sem duplicar o efeito?
Comece pelo caso que costuma ser esquecido: a primeira chamada terminou, mas o
cliente não sabe disso. O teste chama o handler duas vezes com a mesma chave e o
mesmo corpo. A asserção mais importante não é o segundo status. É o contador do
efeito, que deve continuar em 1.
import assert from 'node:assert/strict';
import { test } from 'node:test';
test('reaproveita o resultado concluído sem repetir o efeito', async () => {
let executions = 0;
const handle = createHandler(new IdempotencyStore(), async (body) => {
executions += 1;
return { id: 'order-1', ...body };
});
const first = await handle({ key: 'request-1', body: { item: 'book' } });
const replay = await handle({ key: 'request-1', body: { item: 'book' } });
assert.deepEqual(replay, first);
assert.equal(executions, 1);
});
O runner nativo node:test fornece o contexto e aguarda uma função assíncrona
antes de encerrar o caso, conforme a documentação do test runner do Node.js. Salve o handler e o teste em um arquivo .mjs, mantenha as importações mostradas e execute:
node --test idempotency.test.mjs
Esse caso cobre replay após conclusão. Ele não prova que a gravação da chave e a criação do recurso são atômicas em um banco. Essa diferença precisa aparecer no nome do teste e na documentação do limite.
Como simular duas requisições concorrentes?
Para a concorrência, o primeiro efeito precisa ficar pendente. Uma Promise manual
deixa o segundo pedido chegar antes da conclusão, sem depender de setTimeout ou
de uma corrida acidental do computador.
test('responde conflito enquanto o primeiro efeito está em andamento', async () => {
let executions = 0;
let release;
const pending = new Promise((resolve) => { release = resolve; });
const handle = createHandler(new IdempotencyStore(), async (body) => {
executions += 1;
await pending;
return { id: 'order-2', ...body };
});
const firstPromise = handle({ key: 'request-2', body: { item: 'pen' } });
await Promise.resolve();
const concurrent = await handle({ key: 'request-2', body: { item: 'pen' } });
assert.equal(concurrent.status, 409);
release();
assert.equal((await firstPromise).status, 201);
assert.equal(executions, 1);
});
await Promise.resolve() entrega o controle ao handler sem criar uma espera de
relógio. O teste pode usar outro mecanismo de sincronização, mas a intenção deve
continuar clara: a segunda chamada ocorre enquanto o estado ainda é running.
O status 409 é uma escolha de contrato, não uma exigência universal. A referência
da MDN lista conflito para uma requisição com a mesma chave ainda em processamento, mas o corpo, os headers e a política de retry continuam sendo decisões do serviço.
Por que testar a mesma chave com outro payload?
Uma chave identifica uma intenção, não um usuário livre para reaproveitar o mesmo
texto em qualquer operação. Se a chave request-3 criou um lápis, o mesmo valor não
deve conseguir criar uma régua. Sem essa verificação, um retry atrasado pode receber
ou devolver o resultado de uma operação diferente.
test('rejeita a mesma chave quando o payload muda', async () => {
const handle = createHandler(new IdempotencyStore(), async (body) => ({
id: 'order-3',
...body,
}));
await handle({ key: 'request-3', body: { item: 'pencil' } });
const mismatch = await handle({ key: 'request-3', body: { item: 'ruler' } });
assert.equal(mismatch.status, 422);
});
Esse caso acompanha a ideia de uma impressão do payload. A documentação da MDN descreve a possibilidade de guardar essa impressão e responder erro quando a mesma chave chega com outra. O rascunho do grupo HTTPAPI da IETF detalha a mesma separação entre replay, conflito concorrente e chave usada com payload incompatível, mas ainda é um rascunho, não uma norma final.
Em que camada esse teste deve ficar?
O fixture em memória é um teste rápido do protocolo do handler. Ele confirma a ordem das decisões, a resposta de cada caminho e o contador do efeito. Isso é útil, mas não substitui uma prova na fronteira que realmente protege o recurso.
Use camadas complementares:
- Unidade: teste a impressão do payload, a transição
runningparacompletee as respostas do handler com um armazenamento substituto. - Integração: use o banco, cache ou lock real e envie duas chamadas próximas o bastante para disputar a mesma chave. Verifique uma única gravação observável.
- Contrato do provedor: se a API externa oferece sua própria chave, use o ambiente de teste do provedor e confirme replay, conflito e expiração conforme a documentação. Não transforme o comportamento da Stripe em regra de toda API.
A comparação entre testes unitários e de integração em Node.js ajuda a escolher a fronteira. A pergunta não é quantos testes devem ser unitários. É qual componente precisa permanecer real para provar que duas requisições não repetem o efeito.
Falhas que a fixture não consegue provar
O Map da demonstração é deliberadamente pequeno. Ele não coordena duas instâncias
do processo, não sobrevive a um restart e não expira chaves. Também não torna
atômica a sequência entre guardar a chave, executar a operação e persistir o
resultado. Um banco ou cache distribuído precisa de uma operação de reserva que
tenha a atomicidade adequada ao seu ambiente.
Defina ainda o que acontece quando o efeito falha. Algumas APIs removem uma reserva incompleta e permitem retry. Outras guardam o erro para repetir a mesma resposta. Não escolha por conveniência do teste. Escolha a semântica que o cliente consegue entender e documente o prazo de retenção da chave.
O teste de lógica de retry em JavaScript sem esperar cobre a passagem do tempo e o backoff. Aqui, o foco é diferente: o retry pode acontecer depois de uma resposta perdida, mas o efeito deve continuar sendo uma única ocorrência.
Perguntas frequentes
Testar duas chamadas iguais já prova idempotência?
Não. Isso prova apenas um caminho de replay se o segundo pedido ocorre depois da conclusão. Inclua uma chamada concorrente, uma chave com payload diferente e um contador do efeito. Se a proteção depende de banco ou cache, repita a prova com a fronteira real.
O status da duplicata precisa ser 200?
Não. O contrato pode devolver a resposta original, um conflito enquanto a operação está em andamento ou outro resultado documentado. O importante é que o cliente saiba se deve reutilizar, esperar, corrigir o payload ou iniciar uma nova intenção.
Posso apagar a chave quando o efeito falhar?
Pode, se o contrato tratar a falha como uma tentativa que ainda pode ser repetida. Outra opção é guardar a resposta de erro para que a mesma intenção receba o mesmo resultado. Teste a escolha, porque apagar cedo demais pode permitir uma segunda execução indevida.
Um Map é suficiente em produção?
Não. Ele é suficiente para testar a lógica dentro de um processo. Produção exige persistência, expiração, coordenação entre workers e uma relação definida entre a chave, a impressão do payload, o efeito e a resposta guardada.
Conclusão
O teste mais útil não termina no primeiro 201. Ele repete a intenção depois da
conclusão, mantém uma chamada pendente para testar a concorrência e troca o payload
para verificar o contrato da chave. Em todos os caminhos, conte o efeito observável.
Depois, repita os mesmos casos no armazenamento real. O fixture em memória mostra se o handler escolhe o caminho certo. O teste de integração mostra se a fronteira que impede a duplicação continua certa quando há banco, cache, workers e reinícios.
Fontes consultadas
- MDN,
Idempotency-Keyheader, consultado em 2026-09-22. - Stripe API Reference, Idempotent requests, consultado em 2026-09-22.
- Node.js, Test runner, consultado em 2026-09-22.
- IETF HTTPAPI, Idempotency-Key working draft, consultado em 2026-09-22.
- Mastodon issue #40399, consultado em 2026-09-22. Usado como exemplo de falha observada, não como documentação normativa.