Du klickst einmal auf Speichern. Die Anfrage meldet einen vorübergehenden Fehler, wird wiederholt und ist schließlich erfolgreich. Trotzdem erscheinen zwei identische Notizen. Woher kommt die zweite?

Nehmen wir einen kleinen Dienst an, der pro Anfrage eine Notiz anlegt. Dieses eigenständige JavaScript-Beispiel verliert eine Antwort nach dem Schreibzugriff und vergleicht zwei Clients, die denselben Dienst aufrufen.

Schreibzugriff und Fehler auf einer Zeitachse einordnen

Öffne das herunterladbare Beispiel und lies zuerst den simulierten Transport, dann die Retry-Schleife. Er ruft den Notizdienst auf, protokolliert die zurückgegebene ID und wirft erst danach ResponseLost. Wenn der Client den Fehler erhält, hat der erste Versuch die gespeicherten Daten bereits verändert.

Schritt Sicht des Clients Gespeicherte Notizen
Erster Versuch schreibt Wartet auf Antwort Eine
Transport verliert die Antwort Fehler Eine
Client sendet erneut Wartet erneut Abhängig von Schlüssel und Serverabfrage
Zweite Antwort trifft ein Erfolg Eine oder zwei

Der Fehler liegt bewusst nach dem Schreiben. Vor dem Aufruf würde er einen anderen Fall testen und die Duplikate verbergen. Der Transport wirft seine Fehler deterministisch, ohne Netzwerk oder Timer.

Den Operationsschlüssel vor der Schleife erzeugen

Die Clients unterscheiden sich nur darin, wo sie den Schlüssel erzeugen:

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

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

Bei saveUnsafe erzeugt der Callback bei jedem Senden einen neuen Schlüssel. Der Dienst sieht zwei verschiedene Operationen und legt zwei Notizen an. Bei save wird ein Schlüssel vor den Wiederholungen festgehalten. Beide Versuche übertragen dieselbe Kennung und denselben Titel.

Das Beispiel verwendet nummerierte Zeichenfolgen, damit der Unterschied leicht zu erkennen ist. Sie kennzeichnen einen logischen Speichervorgang, nicht einen einzelnen Übertragungsversuch oder den Titel. Zwei unabhängige Speichervorgänge erhalten auch bei gleichem Titel unterschiedliche Schlüssel. Prüfe die Lebensdauer des Generators: Eine Variable namens „Idempotenzschlüssel“ kann trotzdem innerhalb eines Retry-Wrappers neu erzeugt werden.

Dem Schlüssel bis zur Serverabfrage folgen

Der stabile Schlüssel wirkt hier, weil der Dienst vor dem Anhängen einer Notiz einen Eintrag in completed prüft. Die standardmäßig aktive Option deduplicate erlaubt einem Test, diese Prüfung abzuschalten:

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;

Bei gleichem Schlüssel und Titel gibt er die zuvor erstellte Notiz zurück. Wird derselbe Schlüssel mit einem anderen Titel verwendet, wirft er vor dem nächsten Schreibzugriff einen Fehler. Der Titel bildet hier den gesamten Anfrageinhalt; dieser Vergleich erfasst daher die vollständige Eingabe.

Der Eintrag speichert Eingabe und Ergebnis. Prüfe auch die frühe Rückgabe: Liefe der Code danach bis zum Anhängen weiter, entstünde trotzdem ein Duplikat. Der Test der korrigierten Version prüft sowohl das zurückgegebene Objekt als auch die endgültige Anzahl. Ein weiterer Reproduktionstest schaltet die Deduplizierung auf dem Server aus. Dann entstehen trotz stabilem Client-Schlüssel zwei Notizen.

Fehler ausführen und gespeicherte Ergebnisse vergleichen

Lade retry-idempotency.mjs herunter. Führe die Datei mit Node.js 22 oder neuer aus; weitere Pakete sind nicht nötig:

node --test retry-idempotency.mjs

Alle sechs Tests bestehen. Die beiden mit „reproduce“ im Namen bestehen dann, wenn der beabsichtigte Defekt auftritt. createWithRetry erlaubt insgesamt zwei Versuche und wiederholt nur den simulierten Antwortverlust. Ein Titelkonflikt beendet den Aufruf nach einem Versuch.

Fall Sendevorgänge Gespeicherte Notizen Ergebnis beim Aufrufer
Neuer Schlüssel beim Retry 2 2 Zweite Notiz, note-2
Gleicher Schlüssel, Ergebnis wiederverwendet 2 1 Ursprüngliche Notiz, note-1
Zwei unabhängige Speichervorgänge 2 2 Zwei verschiedene IDs
Anderer Titel bei vorhandenem Schlüssel 1 zusätzlich 1 insgesamt Fehler
Beide Antworten gehen verloren 2 1 Fehler nach dem zweiten Versuch
Gleicher Schlüssel ohne Deduplizierung 2 2 Zweite Notiz

Die fünfte Zeile ist beim Lesen der Fehlerbehandlung besonders hilfreich: Der Client endet mit einem Fehler, obwohl die Notiz existiert. Die Assertions prüfen gespeicherte Daten, Schlüssel der Versuche und zurückgegebene IDs. Eine bloße Prüfung der erfolgreichen Antwort könnte einen doppelten Schreibzugriff übersehen.

In einer Anwendung dieselben fünf Stellen verfolgen

  1. Finde die Benutzeraktion und bestimme, welche Versuche zur selben beabsichtigten Operation gehören.
  2. Suche die Schlüsselerzeugung und folge ihr durch jeden Retry-Wrapper und jedes Anfrageargument.
  3. Öffne den eigentlichen Schreibzugriff und markiere Fehler, die nach dessen Abschluss auftreten können.
  4. Verfolge Serverabfrage, Eingabevergleich und Rückgabe eines früheren Ergebnisses.
  5. Vergleiche Versuchslimit und abschließenden Fehlerpfad mit dem gespeicherten Zustand.

Notiere die zugehörigen Codestellen. Wiederholt ein Wrapper einen Callback, der den Schlüssel erzeugt, lies auch diesen Callback. Den weiteren Weg von der Route bis zur Speicherung behandelt HTTP-Anfragen im Code verfolgen.

Speicherbeispiel und dauerhafte Garantien auseinanderhalten

Der Dienst hält alle Daten in einer Map und einem Array innerhalb eines einzelnen Prozesses. Abfrage und Schreibzugriff laufen synchron. Das Beispiel prüft weder Datenbanktransaktionen noch parallele Worker, Neustarts oder dauerhafte Wiederherstellung. Es bietet keine Exactly-once-Garantie für den Produktivbetrieb.

Eine echte Implementierung braucht Entscheidungen zum Geltungsbereich der Schlüssel, zum Eingabevergleich, zur Aufbewahrungsdauer, zu gleichzeitigen Versuchen und dazu, wie Schreibzugriff und gespeichertes Ergebnis gemeinsam einen Ausfall überstehen. Auch dieser Client vergisst seinen Schlüssel nach dem Speichern. Ein späterer manueller Retry oder ein Neuladen braucht ein eigenes Konzept für die Lebensdauer der Operation. Prüfe dafür den tatsächlichen Speicher- und Client-Code. Der Leitfaden zum Lesen von Code hilft, die relevanten Aufrufer und Definitionen zusammenzuhalten.