Retry-Strategien und Backoff-Algorithmen in PHP
AI generated
8.4
PHP, Resilienz, Architektur-Patterns
Retry-Strategien und Backoff-Algorithmen
in PHP robust implementieren

Einen fehlgeschlagenen Aufruf sofort und wiederholt erneut zu versuchen, klingt nach der naheliegendsten Lösung, verschärft eine Überlastsituation aber häufig erst richtig: Tausende Clients, die zeitgleich denselben gerade erholenden Dienst erneut bestürmen, erzeugen genau die Lastspitze, die den Dienst überhaupt erst in die Knie gezwungen hat. Wer Retry-Logik in PHP sauber mit Exponential Backoff, Jitter und einer klaren Unterscheidung zwischen retrybaren und nicht-retrybaren Fehlern baut, vermeidet dieses Eigentor.

15 Min. Lesezeit Exponential Backoff, Jitter Idempotenz, HTTP-Clients

1. Warum naive, sofortige Retries die Lage oft verschlimmern

Der intuitivste Umgang mit einem fehlgeschlagenen Netzwerkaufruf ist, ihn einfach sofort noch einmal zu versuchen. Bei einem einzelnen Client und einem seltenen, zufälligen Fehler funktioniert das auch gut genug. Sobald aber viele Clients gleichzeitig gegen denselben überlasteten oder gerade neu startenden Dienst laufen, führt sofortiges, unkoordiniertes Wiederholen zu einem Effekt, der als Retry Storm bezeichnet wird: Alle Clients scheitern zur gleichen Zeit, alle versuchen es zur gleichen Zeit erneut, und die dadurch entstehende Lastspitze trifft den Dienst genau in dem Moment, in dem er sich eigentlich erholen sollte.

Dieses Muster ist besonders tückisch, weil es sich selbst verstärkt: Je mehr Clients gleichzeitig retryen, desto länger braucht der Dienst, um wieder stabil zu laufen, was wiederum mehr Timeouts und damit noch mehr gleichzeitige Retries erzeugt. Eine durchdachte Retry-Strategie muss deshalb zwei Dinge gleichzeitig leisten: die Wartezeit zwischen Versuchen mit jedem Fehlschlag vergrößern und die Retry-Zeitpunkte verschiedener Clients gezielt auseinanderziehen, statt sie zu synchronisieren.

2. Exponential Backoff: die Grundformel

Exponential Backoff löst den ersten Teil des Problems, indem die Wartezeit zwischen Versuchen mit jedem weiteren Fehlschlag exponentiell wächst statt konstant zu bleiben. Die Grundformel lautet delay = baseDelay * 2^attempt, wobei attempt bei null beginnt und mit jedem gescheiterten Versuch um eins erhöht wird. Bei einer Basisverzögerung von 100 Millisekunden ergeben sich damit Wartezeiten von etwa 100, 200, 400, 800 und 1600 Millisekunden für die ersten fünf Versuche.

Ohne eine Obergrenze würde diese Formel bei genügend Fehlschlägen aber schnell zu unpraktikabel langen Wartezeiten führen, weshalb in der Praxis immer ein maxDelay als Kappung hinzukommt: delay = min(baseDelay * 2^attempt, maxDelay). Zusätzlich begrenzt eine maximale Anzahl an Versuchen, wie oft überhaupt retryed wird, denn irgendwann muss ein Aufruf endgültig als gescheitert gelten und an den aufrufenden Code zurückgemeldet werden.

3. Jitter: warum reines Exponential Backoff nicht reicht

Reines Exponential Backoff löst zwar das Problem wachsender Wartezeiten, aber nicht das Problem synchronisierter Retry-Wellen: Wenn tausend Clients zur exakt gleichen Millisekunde denselben Fehler erhalten, berechnen alle exakt dieselbe Verzögerung und schlagen exakt zur gleichen Zeit wieder auf den Dienst ein, nur eben etwas später. Genau hier setzt Jitter an, indem er der berechneten Verzögerung eine zufällige Komponente hinzufügt und damit die Retry-Zeitpunkte der einzelnen Clients auseinanderzieht.

Die gängigste Variante ist Full Jitter, bei der die tatsächliche Wartezeit gleichverteilt zufällig zwischen null und dem berechneten Exponential-Backoff-Wert gewählt wird: delay = random(0, min(maxDelay, baseDelay * 2^attempt)). Eine Alternative ist Equal Jitter, bei der die Hälfte der berechneten Verzögerung fest garantiert bleibt und nur die andere Hälfte zufällig variiert, was etwas vorhersagbarere, aber immer noch entzerrte Wartezeiten liefert.

4. Eine eigene RetryPolicy-Klasse in PHP

Eine wiederverwendbare RetryPolicy-Klasse kapselt die gesamte Backoff-Logik hinter einer einzigen execute()-Methode, die eine beliebige Callback-Funktion entgegennimmt und bei einem retrybaren Fehler automatisch mit wachsender, jitter-behafteter Verzögerung erneut versucht.

Für Tests ist es wichtig, die eigentliche Wartefunktion austauschbar zu machen, damit ein Unit-Test nicht tatsächlich mehrere Sekunden lang schläft, sondern die injizierte Ersatzfunktion sofort zurückkehren lässt und nur prüft, wie oft und mit welchen Werten sie aufgerufen wurde.


<?php

declare(strict_types=1);

namespace App\Resilience;

final class RetryPolicy
{
    /**
     * @param callable(\Throwable):bool|null $isRetryable Decides whether a caught
     *        exception represents a transient failure worth retrying.
     * @param \Closure|null $sleep Injectable sleep function, replaced with a
     *        no-op in tests to avoid real delays.
     */
    public function __construct(
        private readonly int $maxAttempts = 5,
        private readonly int $baseDelayMs = 100,
        private readonly int $maxDelayMs = 10_000,
        private readonly mixed $isRetryable = null,
        private readonly ?\Closure $sleep = null,
    ) {
    }

    /**
     * @template T
     * @param callable():T $operation
     * @return T
     */
    public function execute(callable $operation)
    {
        $attempt = 0;

        while (true) {
            try {
                return $operation();
            } catch (\Throwable $e) {
                $attempt++;
                $retryable = $this->isRetryable !== null ? ($this->isRetryable)($e) : true;

                if (!$retryable || $attempt >= $this->maxAttempts) {
                    throw $e;
                }

                $this->wait($attempt);
            }
        }
    }

    private function wait(int $attempt): void
    {
        $exponential = $this->baseDelayMs * (2 ** $attempt);
        $capped = min($exponential, $this->maxDelayMs);
        $withJitter = random_int(0, (int) $capped);

        $sleeper = $this->sleep ?? fn (int $ms) => usleep($ms * 1000);
        $sleeper($withJitter);
    }
}

5. Retrybare und nicht-retrybare Fehler unterscheiden

Nicht jeder Fehlschlag sollte wiederholt werden. Transiente Fehler wie Timeouts, abgebrochene Verbindungen oder Serverfehler mit den Statuscodes 502, 503 und 504 deuten auf eine vorübergehende Störung hin und sind gute Kandidaten für einen Retry. Permanente Fehler wie eine ungültige Eingabe mit Statuscode 400, eine fehlende Berechtigung mit Statuscode 401 oder 403 oder eine verletzte Geschäftsregel ändern sich dagegen beim erneuten Versuch nicht, ein Retry verschwendet hier nur Zeit und verzögert die eigentlich sofort fällige Fehlermeldung.

Noch wichtiger ist die Frage der Idempotenz: Nur idempotente Operationen wie ein GET, ein vollständig ersetzendes PUT oder ein DELETE lassen sich gefahrlos blind wiederholen, weil ein mehrfacher Aufruf denselben Endzustand erzeugt. Ein nicht-idempotenter Aufruf wie ein POST, der eine neue Bestellung anlegt, darf dagegen nicht blind retryed werden, denn wenn die ursprüngliche Antwort nur verloren ging, der Vorgang serverseitig aber trotzdem erfolgreich war, entsteht bei einem blinden Retry eine doppelte Bestellung. Hier hilft nur ein vom Client erzeugter Idempotenzschlüssel, den der Server bei einem wiederholten Aufruf erkennt und dedupliziert.

6. Praxisbeispiel: ein HTTP-Client-Wrapper mit Retry-Entscheidungslogik

In der Praxis lohnt sich ein schlanker HTTP-Client-Wrapper, der die RetryPolicy mit einer klaren Entscheidungslogik kombiniert: Serverfehler und Timeouts gelten als retrybar, Statuscodes ab 400 werden als permanenter Fehler ohne weiteren Versuch nach oben gereicht.

Bei schreibenden Aufrufen wird zusätzlich ein Idempotenzschlüssel mitgeschickt, den der Server als eindeutige Kennung des ursprünglichen Vorgangs speichert und bei einem wiederholten Aufruf mit demselben Schlüssel einfach die erste, bereits verarbeitete Antwort erneut zurückgibt, statt den Vorgang ein zweites Mal auszuführen.


<?php

declare(strict_types=1);

namespace App\Http;

use App\Resilience\RetryPolicy;

final class PermanentHttpException extends \RuntimeException
{
    public function __construct(public readonly int $statusCode, string $body)
    {
        parent::__construct("Request failed permanently with status {$statusCode}: {$body}");
    }
}

final class ResilientHttpClient
{
    public function __construct(private readonly RetryPolicy $retryPolicy)
    {
    }

    public function post(string $url, array $body, ?string $idempotencyKey = null): array
    {
        return $this->retryPolicy->execute(function () use ($url, $body, $idempotencyKey) {
            $headers = ['Content-Type: application/json'];
            if ($idempotencyKey !== null) {
                $headers[] = "Idempotency-Key: {$idempotencyKey}";
            }

            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_POST => true,
                CURLOPT_HTTPHEADER => $headers,
                CURLOPT_POSTFIELDS => json_encode($body),
                CURLOPT_TIMEOUT => 5,
                CURLOPT_RETURNTRANSFER => true,
            ]);
            $response = curl_exec($ch);
            $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            if ($response === false || in_array($status, [502, 503, 504], true)) {
                throw new \RuntimeException("Request to {$url} failed with status {$status}");
            }
            if ($status >= 400) {
                // A 4xx client error will not succeed on retry, so it is
                // reported as a permanent failure instead of a transient one.
                throw new PermanentHttpException($status, (string) $response);
            }

            return json_decode($response, true);
        });
    }
}

// Wiring: only exceptions that are NOT a PermanentHttpException are retried.
$retryPolicy = new RetryPolicy(
    maxAttempts: 4,
    isRetryable: static fn (\Throwable $e) => !($e instanceof PermanentHttpException),
);

7. Retry und Circuit Breaker sinnvoll kombinieren

Retry-Logik und der Circuit Breaker aus dem vorherigen Artikel lösen unterschiedliche Teilprobleme derselben Situation und ergänzen sich deshalb gut: Der Retry-Mechanismus federt einzelne, vorübergehende Fehlschläge innerhalb eines Requests ab, während der Circuit Breaker über viele Requests hinweg erkennt, dass ein Dienst dauerhaft ausgefallen ist, und weitere Aufrufe dann komplett unterbindet, statt sie erst noch mehrfach erfolglos zu versuchen.

In der Reihenfolge wird zuerst der Zustand des Circuit Breakers geprüft, und nur wenn er geschlossen oder testweise halb offen ist, greift innerhalb dieses einzelnen Aufrufs zusätzlich die Retry-Logik. So verhindert der Breaker, dass ein bereits als ausgefallen erkannter Dienst durch viele parallele Retry-Versuche zusätzlich belastet wird, während Retries weiterhin normale, kurze Ausreißer abfangen, ohne gleich den ganzen Breaker auszulösen.

8. Zeitbudget und Obergrenzen: Retries dürfen die Antwortzeit nicht sprengen

Neben der maximalen Anzahl an Versuchen lohnt sich zusätzlich ein globales Zeitbudget für die gesamte Operation, etwa fünf Sekunden insgesamt, unabhängig davon, wie viele einzelne Versuche darin stattfinden. Ohne ein solches Budget kann eine Kette aus mehreren, für sich genommen plausiblen Wartezeiten in Summe trotzdem zu einer für Nutzer inakzeptabel langen Antwortzeit führen.

In verteilten Systemen mit mehreren Hops ist es zusätzlich sinnvoll, das verbleibende Zeitbudget als Deadline an nachgelagerte Aufrufe weiterzureichen, damit ein tiefer verschachtelter Retry nicht ein Zeitbudget aufbraucht, das auf einer höheren Ebene der Aufrufkette bereits fast erschöpft war. Ohne diese Weitergabe kann die Gesamtantwortzeit trotz sauber konfigurierter Einzelkomponenten am Ende trotzdem unkontrolliert anwachsen.

9. Häufige Fehler bei Retry-Implementierungen

Der gravierendste Fehler ist das blinde Wiederholen nicht-idempotenter Schreiboperationen ohne Idempotenzschlüssel, was zu doppelt angelegten Bestellungen, doppelten Zahlungen oder anderen inkonsistenten Zuständen führen kann. Fast ebenso häufig fehlt Jitter komplett, wodurch reines Exponential Backoff bei vielen gleichzeitig scheiternden Clients weiterhin synchronisierte Lastspitzen erzeugt, nur eben zeitlich etwas verzögert statt sofort.

Ein dritter häufiger Fehler ist eine fehlende oder zu hoch angesetzte Obergrenze für die Anzahl der Versuche, wodurch ein Aufruf im schlimmsten Fall minutenlang mit immer länger werdenden Wartezeiten weiterläuft, bevor er endgültig scheitert. Und schließlich wird beim Kapazitätsplanen der nachgelagerten Dienste oft vergessen, dass ein Dienst, der mit X Anfragen pro Sekunde ausgelegt ist, während eines Ausfalls durch Retries kurzzeitig ein Vielfaches davon abbekommen kann, wenn viele Clients gleichzeitig mehrfach retryen.

Strategie Formel (vereinfacht) Vorteil Nachteil
Fixed Delay delay = konstant Einfach zu implementieren Keine Anpassung an Fehlerhäufigkeit
Linear Backoff delay = baseDelay * attempt Etwas Entlastung des Dienstes Wächst zu langsam bei anhaltenden Ausfällen
Exponential Backoff delay = baseDelay * 2^attempt Schnelle Entlastung bei Serienfehlern Ohne Kappung theoretisch unbegrenzt
Exponential Backoff mit Jitter delay = random(0, min(maxDelay, baseDelay*2^attempt)) Verhindert synchronisierte Retry-Wellen Etwas unvorhersagbarere Einzel-Wartezeit
Exponential Backoff mit Kappung delay = min(baseDelay*2^attempt, maxDelay) Verhindert unpraktikabel lange Wartezeiten Bei Dauerausfall ohne Circuit Breaker wirkungslos

Mironsoft

PHP-Modernisierung, Code-Qualität und Legacy-Refactoring

Gewachsener PHP-Code, der niemand mehr gern anfasst?

Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.

Legacy-Refactoring

Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.

Code-Qualität etablieren

PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.

Versions-Upgrade

PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.

10. Zusammenfassung

Retry und Backoff in PHP: Das Wichtigste auf einen Blick

Kernproblem

Sofortiges, unkoordiniertes Wiederholen erzeugt Retry Storms, statt Fehler zu beheben.

Backoff

Exponentiell wachsende Wartezeit mit Jitter entzerrt die Retry-Zeitpunkte verschiedener Clients.

Fehlerklassifizierung

Nur transiente Fehler retryen, permanente Fehler und Idempotenz-Verletzungen konsequent ausschließen.

Kombination

Retry deckt einzelne Ausreißer ab, ein Circuit Breaker stoppt Aufrufe bei anhaltenden Ausfällen ganz.

11. FAQ: Retry und Backoff in PHP: Das Wichtigste auf einen Blick

1Was ist Exponential Backoff?
Eine Retry-Strategie, bei der die Wartezeit zwischen aufeinanderfolgenden Versuchen mit jedem Fehlschlag exponentiell wächst, üblicherweise durch Verdopplung der vorherigen Verzögerung bis zu einer festen Obergrenze.
2Warum reicht Exponential Backoff allein nicht aus?
Weil viele Clients, die gleichzeitig denselben Fehler erhalten, ohne zusätzliche Zufallskomponente auch exakt zur gleichen Zeit wieder retryen und damit erneut eine synchronisierte Lastspitze erzeugen.
3Was ist Jitter und wie wirkt er?
Eine zufällige Komponente, die zur berechneten Backoff-Verzögerung addiert oder mit ihr kombiniert wird, sodass die tatsächlichen Retry-Zeitpunkte verschiedener Clients zeitlich auseinandergezogen statt synchronisiert werden.
4Welche Fehler sollte ich überhaupt retryen?
Nur transiente Fehler wie Timeouts, Verbindungsabbrüche oder Serverfehler mit den Statuscodes 502, 503 und 504. Fachliche Fehler wie ungültige Eingaben mit Statuscode 400 sollten nicht retryed werden.
5Warum ist Idempotenz bei Retries so wichtig?
Weil ein blind wiederholter, nicht-idempotenter Aufruf wie ein POST zur Erstellung einer Bestellung ohne Idempotenzschlüssel dieselbe Bestellung mehrfach anlegen kann, wenn die ursprüngliche Antwort nur verloren ging, der Vorgang serverseitig aber trotzdem erfolgreich war.
6Was ist ein Idempotenzschlüssel?
Ein vom Client generierter, eindeutiger Wert, den der Server bei wiederholten Aufrufen erkennt, um denselben Vorgang nicht mehrfach auszuführen, sondern beim zweiten Aufruf einfach die erste Antwort erneut zurückzugeben.
7Wie kombiniere ich Retry-Logik mit einem Circuit Breaker?
Der Circuit Breaker wird zuerst geprüft und blockiert Aufrufe komplett, sobald ein Dienst dauerhaft ausfällt, während die Retry-Logik innerhalb des geschlossenen Zustands einzelne, vorübergehende Fehlschläge abfedert.
8Wie viele Versuche sind sinnvoll?
Meist zwischen drei und fünf Versuchen, abhängig vom akzeptablen Zeitbudget der aufrufenden Operation. Deutlich mehr Versuche verlängern die Antwortzeit oft unnötig, ohne die Erfolgswahrscheinlichkeit spürbar zu erhöhen.
9Was ist ein Zeitbudget bei Retries?
Eine zusätzliche Obergrenze für die insgesamt für alle Versuche zusammen aufgewendete Zeit, unabhängig von der Anzahl der Versuche, damit eine Anfrage trotz vieler kurzer Wartezeiten nicht insgesamt viel zu lange dauert.
10Wie teste ich Retry-Logik, ohne echte Wartezeiten im Test zu erzeugen?
Indem die eigentliche Wartefunktion als austauschbare Abhängigkeit injiziert wird, sodass ein Test sie durch eine sofort zurückkehrende Funktion ersetzen und trotzdem prüfen kann, wie oft und mit welchen Werten sie aufgerufen wurde.