Você clica em Salvar uma vez. A requisição falha temporariamente, tenta de novo e termina com sucesso. Mesmo assim, aparecem duas notas idênticas. De onde veio a segunda?

Imagine um pequeno serviço que cria uma nota por requisição. Este exemplo independente em JavaScript perde uma resposta depois da gravação e compara dois clientes que chamam o mesmo serviço.

Coloque a gravação e a falha na mesma sequência

Abra o exemplo para download e leia o transporte antes do laço de tentativas. Ele chama o serviço de notas, registra o ID retornado e só então lança ResponseLost. Quando o cliente recebe o erro, a primeira tentativa já alterou os dados.

Etapa O que o cliente vê Notas armazenadas
A primeira tentativa grava Espera pela resposta Uma
O transporte perde a resposta Um erro Uma
O cliente envia novamente Espera outra vez Depende da chave e da consulta no servidor
A segunda resposta chega Sucesso Uma ou duas

A falha fica de propósito depois da gravação. Movê-la para antes da chamada testaria outro caso e esconderia a duplicação. O transporte lança erros de forma determinística, sem rede ou temporizadores.

Crie a chave da operação antes de entrar no laço

A única diferença entre os clientes é onde a chave é gerada:

function saveUnsafe(send, title, newKey) {
  return createWithRetry(send, title, () => newKey());
}

function save(send, title, newKey) {
  const key = newKey();
  return createWithRetry(send, title, () => key);
}

Em saveUnsafe, o callback gera uma chave nova a cada envio do laço. O serviço entende que são duas operações e cria duas notas. Em save, uma chave é capturada antes das tentativas, de modo que ambas levam o mesmo identificador e título.

O exemplo usa strings numeradas para facilitar a comparação. Elas identificam uma operação lógica de salvar, e não cada tentativa de transporte ou o título. Duas operações independentes com títulos iguais ainda recebem chaves distintas. Confira o tempo de vida do gerador: uma variável chamada “chave de idempotência” também pode ser recriada dentro de um wrapper de tentativas.

Siga a chave até a consulta no servidor

A chave estável funciona aqui porque o serviço consulta uma entrada em completed antes de acrescentar a nota. A opção deduplicate, ativada por padrão, permite que um teste desligue essa consulta:

if (deduplicate && completed.has(key)) {
  const previous = completed.get(key);
  if (previous.title !== title)
    throw new Error('key reused with another title');
  return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;

Se a chave e o título se repetem, ele retorna a nota criada antes. Se a mesma chave vier com outro título, lança um erro antes de gravar novamente. O título é todo o conteúdo da requisição neste exemplo; por isso, essa comparação cobre a entrada inteira.

A entrada guarda os dados recebidos e o resultado. Confira o retorno antecipado: continuar até a inserção ainda criaria uma duplicata. O teste da versão corrigida verifica o objeto retornado e a quantidade final de notas. Outra reprodução desativa a deduplicação no servidor: mesmo com uma chave estável no cliente, duas notas são criadas.

Execute as falhas e compare o que ficou salvo

Baixe retry-idempotency.mjs. Use Node.js 22 ou posterior, sem instalar dependências:

node --test retry-idempotency.mjs

Os seis testes passam. Os dois com “reproduce” no nome passam quando o defeito intencional acontece. createWithRetry permite duas tentativas no total e repete apenas o erro simulado de resposta perdida; um conflito de título encerra a execução após uma tentativa.

Caso Envios Notas salvas Resultado para quem chamou
Chave nova a cada tentativa 2 2 Segunda nota, note-2
Mesma chave com resultado reutilizado 2 1 Nota original, note-1
Duas operações de salvar independentes 2 2 Dois IDs distintos
Outro título com uma chave existente 1 adicional 1 no total Rejeição
As duas respostas são perdidas 2 1 Erro após a segunda tentativa
Mesma chave sem deduplicação 2 2 Segunda nota

A quinta linha ajuda na leitura do tratamento de erros: o cliente termina com erro, mas a nota existe. As asserções examinam os dados salvos, as chaves de cada tentativa e os IDs retornados. Verificar apenas uma resposta bem-sucedida poderia esconder a gravação duplicada.

Percorra os mesmos cinco pontos na aplicação

  1. Encontre a ação do usuário e decida quais tentativas pertencem à mesma operação pretendida.
  2. Localize a criação da chave e siga seu caminho por cada wrapper de tentativas e argumento da requisição.
  3. Abra a gravação real e marque as falhas que podem ocorrer depois de sua conclusão.
  4. Acompanhe a consulta no servidor, a comparação da entrada e o retorno de um resultado anterior.
  5. Compare o limite de tentativas e a saída final de erro com o estado armazenado.

Anote os locais no código. Se um wrapper repete um callback que cria a chave, leia esse callback também. Para seguir o caminho completo da rota ao armazenamento, veja como rastrear uma requisição HTTP no código.

Separe o exemplo em memória das garantias duráveis

O serviço armazena tudo em um Map e um array dentro de um único processo. A consulta e a gravação são síncronas. O exemplo não testa transações de banco, workers concorrentes, reinicializações ou recuperação persistente, nem oferece garantia de exactly-once em produção.

Uma implementação real precisa decidir o escopo da chave, a comparação da entrada, o prazo de retenção, o tratamento de tentativas concorrentes e como a gravação e o resultado salvo sobrevivem juntos a uma falha. Este cliente também esquece a chave quando Salvar termina: uma nova tentativa manual ou um recarregamento exigem outro desenho para o tempo de vida da operação. Confira o armazenamento e o cliente reais. O guia de leitura de código ajuda a manter definições e pontos de chamada próximos enquanto você os acompanha.