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.

Diagrama mostra uma requisição passando por uma barreira de idempotência e seguindo para execução, replay, conflito em andamento ou payload diferente.

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:

  1. Unidade: teste a impressão do payload, a transição running para complete e as respostas do handler com um armazenamento substituto.
  2. 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.
  3. 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