Vous cliquez une seule fois sur Enregistrer. La requête échoue temporairement, recommence et finit par réussir. Pourtant, deux notes identiques apparaissent. D’où vient la seconde ?
Imaginons un petit service qui crée une note par requête. Cet exemple JavaScript autonome perd une réponse après l’écriture et compare deux clients qui appellent le même service.
Placer l’écriture et l’échec sur une même chronologie
Ouvrez l’exemple téléchargeable et lisez le transport avant la boucle de reprise. Il appelle le service de notes, consigne l’ID renvoyé, puis seulement lève ResponseLost. Quand le client reçoit l’erreur, la première tentative a déjà modifié les données.
| Étape | Ce que voit le client | Notes enregistrées |
|---|---|---|
| La première tentative écrit | Attend une réponse | Une |
| Le transport perd la réponse | Une erreur | Une |
| Le client renvoie la requête | Attend à nouveau | Dépend de la clé et de la recherche côté serveur |
| La seconde réponse arrive | Succès | Une ou deux |
L’échec est volontairement placé après l’écriture. Le déplacer avant l’appel testerait un autre cas et masquerait le doublon. Le transport lève ses erreurs de façon déterministe, sans réseau ni minuterie.
Créer la clé de l’opération avant d’entrer dans la boucle
Les deux clients diffèrent uniquement par l’endroit où ils créent la clé :
function saveUnsafe(send, title, newKey) {
return createWithRetry(send, title, () => newKey());
}
function save(send, title, newKey) {
const key = newKey();
return createWithRetry(send, title, () => key);
}
Dans saveUnsafe, le callback crée une nouvelle clé à chaque envoi de la boucle. Le service reconnaît deux opérations distinctes et crée deux notes. Dans save, une clé est conservée avant les reprises : les deux tentatives transmettent le même identifiant et le même titre.
L’exemple utilise des chaînes numérotées pour faciliter la comparaison. Elles identifient une opération logique d’enregistrement, pas chaque tentative de transport ni le titre. Deux enregistrements indépendants reçoivent des clés différentes, même avec un titre identique. Vérifiez la durée de vie du générateur : une variable nommée « clé d’idempotence » peut quand même être recréée dans une fonction de reprise.
Suivre la clé jusqu’à la recherche côté serveur
La clé stable fonctionne ici parce que le service cherche une entrée dans completed avant d’ajouter la note. L’option deduplicate, activée par défaut, permet à un test de désactiver cette recherche :
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;
Si la clé et le titre sont identiques, il renvoie la note créée précédemment. Si une même clé arrive avec un autre titre, il lève une erreur avant toute nouvelle écriture. Le titre constitue tout le contenu de la requête dans cet exemple : la comparaison couvre donc l’intégralité de l’entrée.
L’entrée conserve les données reçues et le résultat. Vérifiez le retour anticipé : poursuivre jusqu’à l’ajout créerait encore un doublon. Le test de la version corrigée vérifie l’objet renvoyé ainsi que le nombre final de notes. Un autre test de reproduction désactive la déduplication côté serveur : la clé stable du client produit alors deux notes.
Exécuter les échecs et comparer les données conservées
Téléchargez retry-idempotency.mjs. Exécutez-le avec Node.js 22 ou une version ultérieure, sans installer de dépendances :
node --test retry-idempotency.mjs
Les six tests passent. Les deux dont le nom contient « reproduce » passent lorsque le défaut voulu se produit. createWithRetry autorise deux tentatives au total et ne reprend que l’erreur simulant une réponse perdue. Un conflit de titre s’arrête après une seule tentative.
| Cas | Envois | Notes enregistrées | Résultat pour l’appelant |
|---|---|---|---|
| Nouvelle clé à chaque reprise | 2 | 2 | Seconde note, note-2 |
| Même clé, résultat réutilisé | 2 | 1 | Note initiale, note-1 |
| Deux enregistrements indépendants | 2 | 2 | Deux ID distincts |
| Nouveau titre avec une clé existante | 1 supplémentaire | 1 au total | Rejet |
| Les deux réponses sont perdues | 2 | 1 | Erreur après la seconde tentative |
| Même clé sans déduplication | 2 | 2 | Seconde note |
La cinquième ligne aide à lire la gestion des erreurs : le client finit en erreur alors que la note existe. Les assertions examinent les données enregistrées, les clés des tentatives et les ID renvoyés. Vérifier uniquement une réponse réussie pourrait laisser passer une écriture en double.
Retrouver les cinq mêmes points dans une application
- Repérez l’action utilisateur et déterminez quelles tentatives appartiennent à la même opération voulue.
- Localisez la création de la clé et suivez-la dans chaque fonction de reprise et argument de requête.
- Ouvrez l’écriture réelle et marquez les erreurs possibles après son achèvement.
- Suivez la recherche du serveur, la comparaison des entrées et le retour d’un ancien résultat.
- Comparez la limite de tentatives et la sortie finale en erreur avec l’état enregistré.
Notez les emplacements dans le code. Si une fonction de reprise réexécute un callback qui crée la clé, lisez aussi ce callback. Pour parcourir le trajet de la route au stockage, consultez suivre une requête HTTP dans le code.
Distinguer l’exemple en mémoire des garanties durables
Ce service conserve tout dans une Map et un tableau au sein d’un seul processus. La recherche et l’écriture sont synchrones. Il ne teste ni transactions de base de données, ni workers concurrents, ni redémarrages, ni récupération persistante. Il n’offre aucune garantie exactly-once en production.
Une implémentation réelle doit préciser la portée des clés, la comparaison des entrées, la durée de conservation, le traitement des tentatives simultanées et la façon dont l’écriture et le résultat enregistré survivent ensemble à une panne. Ce client oublie aussi sa clé lorsque l’enregistrement se termine. Une reprise manuelle ultérieure ou un rechargement demandent une conception distincte de la durée de vie de l’opération. Examinez le stockage et le client réels. Le guide de lecture du code vous aide à garder les appelants et les définitions utiles à portée de main.