Der Vorfall dauerte 40 Minuten. Der Auslöser dauerte 90 Sekunden. Ein Dienst war für anderthalb Minuten überlastet, weil ein Deploy eine Verbindung nicht sauber geschlossen hatte. Danach war er wieder gesund — aber die Last ging nicht zurück, weil jeder Aufrufer, der während der 90 Sekunden einen Fehler bekommen hatte, es weiter versuchte.

Das ist der Moment, in dem ich verstanden habe: Ein Retry ist keine Fehlerbehandlung, sondern eine Lastvervielfachung. Er ist nützlich, wenn der Fehler vorübergehend ist und die Wiederholung gestaffelt kommt. Er ist katastrophal, wenn der Fehler durch Überlast entstanden ist und die Wiederholung sofort kommt. Denn dann ist der Retry kein Reparaturversuch, sondern eine Verstärkung.

Die Multiplikation, die niemand im Diagramm sieht

In unserem Fall gab es drei Ebenen: der Browser mit zwei Versuchen, das Gateway mit drei, der Service selbst mit drei. Bei einem einzelnen Nutzerklick sind das im schlechtesten Fall 2 x 3 x 3 = 18 Anfragen an die Datenbank. Bei 2.000 Klicks pro Minute sind es 36.000. Die Datenbank war für 4.000 ausgelegt. Sie war nicht kaputt, sie war nur mit Wiederholungen beschäftigt.

-- Wie oft wurde dieselbe Anfrage wiederholt?
-- idempotency_key wird vom Aufrufer gesetzt und bleibt bei Retries gleich.
SELECT
    idempotency_key,
    count(*)                    AS versuche,          -- alle Versuche inkl. Retries
    count(DISTINCT trace_id)    AS kettenglieder,     -- wie viele Ebenen mitgemacht haben
    min(ts)                     AS erster_versuch,
    max(ts) - min(ts)           AS spanne,
    count(*) FILTER (WHERE status >= 500) AS fehlschlaege
FROM gateway_access_log
WHERE ts >= '2025-11-14 09:40:00'   -- Beginn des Vorfalls
  AND ts <  '2025-11-14 10:20:00'
GROUP BY idempotency_key
HAVING count(*) > 5                  -- nur die auffaelligen Ketten
ORDER BY versuche DESC
LIMIT 20;

Das Ergebnis: 1.412 Schlüssel mit mehr als fünf Versuchen, der Spitzenwert bei 31 Versuchen für einen einzigen Klick. Die Spanne zwischen erstem und letztem Versuch lag in der Spitze bei 22 Minuten. Das heißt: Ein Nutzer hat 22 Minuten lang alle paar Sekunden eine Anfrage ausgelöst, ohne dass irgendwo stand, dass es seine ist. Aus Sicht des Systems waren das 31 unabhängige Anfragen. Aus Sicht des Nutzers war es ein Klick und ein Ladekreis.

Was exponentielles Backoff wirklich ändert

Diagramm mit auslaufender Retry-Kurve und einer zweiten Kurve mit Jitter
Ohne Jitter wiederholen alle zur gleichen Zeit. Mit Jitter verteilt sich die Last, der Fehler bleibt derselbe.

Die Idee ist einfach: Warte beim ersten Retry 100 Millisekunden, beim zweiten 200, beim dritten 400, und höre nach drei Versuchen auf. Das reduziert die Last pro Nutzer von 31 auf 3 und verteilt sie über 700 Millisekunden statt über 22 Minuten. Der zweite Teil ist genauso wichtig: Jitter. Ohne Jitter warten alle Clients exakt 200 Millisekunden und feuern dann gleichzeitig — der sogenannte Thundering Herd. Mit vollem Jitter wartet jeder eine zufällige Zeit zwischen null und der berechneten Obergrenze, und die Last verteilt sich.

Bei uns hat die Umstellung zwei Dinge geändert. Die Wiederholungen pro Vorfall fielen um 94 Prozent. Und die Zeit bis zur Erholung sank von 40 Minuten auf unter 3, weil der Dienst nach der Reparatur nicht mehr mit einer Welle von Nachzüglern zu kämpfen hatte. Das ist der Punkt, den ich vorher nicht verstanden hatte: Retries ohne Backoff verlängern einen Ausfall nicht, sie erzeugen ihn. Der ursprüngliche Fehler war nach 90 Sekunden weg. Die 38 Minuten danach waren selbstgemacht.

Die drei Regeln, die bei uns jetzt gelten

Erstens: Nur idempotente Operationen werden wiederholt. Ein GET, ein PUT mit vollständigem Body, ein DELETE — ja. Ein POST ohne Idempotenz-Key — nein, niemals. Wenn der Aufrufer keinen Key schickt, erzeugt das Gateway einen und gibt ihn im Fehlerfall zurück, damit der Client denselben Key beim Retry verwendet. Ohne diese Regel erzeugst du bei jedem Ausfall doppelte Bestellungen, und die siehst du erst in der Abrechnung.

Zweitens: ein Retry-Budget statt einer Retry-Regel. Wir erlauben höchstens 10 Prozent zusätzliche Anfragen durch Wiederholungen, gemessen pro Minute über alle Clients. Ist das Budget aufgebraucht, wird nicht mehr wiederholt, sondern schnell fehlgeschlagen.

Drittens: Ein Circuit Breaker vor dem Retry. Wenn ein Upstream in 30 Sekunden 50 Fehler hatte, wird er für 10 Sekunden nicht mehr angesprochen, und die Anfragen fallen sofort mit einem klaren Fehler zurück.

Was ich daraus für die Abschlussarbeit mitnehme

Ich schreibe gerade über Resilienzmuster in verteilten Systemen, und dieses Erlebnis ist mein Fallbeispiel. Was mich daran interessiert, ist nicht die Technik — Backoff ist eine Formel mit vier Zeilen — sondern die Organisation. Drei Teams hatten jeweils eine vernünftige Retry-Regel. Jedes für sich hätte man mit gutem Gewissen abnehmen können. In Summe ergaben sie einen Lasttest, den niemand bestellt hatte, weil niemand die Multiplikation über Ebenen gesehen hat. Das ist ein Koordinationsproblem, kein Programmierproblem, und es wird in keinem Code-Review sichtbar.

Deshalb steht bei uns jetzt in der Architekturrichtlinie ein Satz, der eigentlich selbstverständlich sein sollte: Retries werden pro Anfragekette gezählt, nicht pro Dienst. Ein Aufruf darf insgesamt drei Versuche haben, egal wie viele Dienste auf dem Weg liegen. Das umzusetzen war unangenehm, weil dafür ein Header durch alle Dienste musste — aber es ist die einzige Regel, die die Multiplikation tatsächlich begrenzt.

Fazit

Ein Retry ist ein Versprechen an den Nutzer, dass es gleich nochmal klappt. Ohne Backoff ist es ein Versprechen an den Server, dass er gleich nochmal gefragt wird. Der Unterschied kostet im schlechten Fall 40 Minuten Verfügbarkeit, und er entsteht nicht durch einen Fehler, sondern durch drei vernünftige Entscheidungen in drei Teams. Wie viele Versuche macht eine Anfrage bei euch, wenn man alle Ebenen zusammenzählt — und weiß das jemand, oder ist die Zahl nur zufällig drei mal drei?