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
- Encontre a ação do usuário e decida quais tentativas pertencem à mesma operação pretendida.
- Localize a criação da chave e siga seu caminho por cada wrapper de tentativas e argumento da requisição.
- Abra a gravação real e marque as falhas que podem ocorrer depois de sua conclusão.
- Acompanhe a consulta no servidor, a comparação da entrada e o retorno de um resultado anterior.
- 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.