Webentwicklung

Ausgehende HTTP-Anfragen in PHP - Zeitgrenzen vor Wiederholungen festlegen

Ausgehende HTTP-Anfragen in PHP - Zeitgrenzen vor Wiederholungen festlegen

Eine kleine PHP-Anwendung kann eine entfernte API mit nur wenigen Zeilen aufrufen. Die schwierigere Frage stellt sich, wenn das andere System langsam oder nicht erreichbar ist oder nach dem Empfang der Anfrage nicht antwortet. Wie lange sollte die Anwendung warten? Sollte sie es erneut versuchen? Wenn die erste Anfrage in einen Timeout läuft, hat der entfernte Dienst dann nichts getan, oder ist lediglich seine Antwort nicht zurückgekommen?

Es gibt weder einen einzelnen Timeout-Wert noch eine Anzahl von Wiederholungen, die jeden Fall abdeckt. Eine Wetterabfrage, eine Zahlungsanforderung, eine Webhook-Zustellung und eine Synchronisierung im Hintergrund haben unterschiedliche Kosten und Fehlersemantiken. Ein sinnvoller Entwurf beginnt daher mit Grenzen: jeden Versuch begrenzen, den tatsächlichen Fehler unterscheiden, die Sicherheit einer Wiederholung klären und den gesamten Vorgang begrenzen. Dieser Artikel wendet dieses Modell auf die cURL-Erweiterung von PHP an, ohne zu behaupten, dass eine kurze Hilfsfunktion jedes Problem verteilter Systeme lösen kann.

Ein entfernter Aufruf schafft eine unsichere Grenze

Eine ausgehende Anfrage durchläuft mehrere Komponenten, die der PHP-Prozess nicht kontrolliert. Die DNS-Auflösung kann stocken, eine TCP-Verbindung kann scheitern, ein TLS-Handshake kann abbrechen, der Server kann einen Fehler zurückgeben oder die Antwort kann verloren gehen, nachdem der Server seinen Zustand bereits geändert hat. Für einen Benutzer, der auf eine Seite wartet, können diese Ergebnisse ähnlich aussehen. Als Belege sind sie jedoch nicht austauschbar.

Die erste defensive Regel ist einfach: Unbegrenzt zu warten ist keine Wiederherstellungsstrategie. Laut offizieller libcurl-Dokumentation hat CURLOPT_TIMEOUT_MS standardmäßig den Wert null, was bedeutet, dass kein Transfer-Timeout gilt. Ein Framework oder SDK kann eigene Standardwerte setzen, doch reiner PHP-cURL-Code sollte dies nicht voraussetzen.

Ein Timeout beweist nicht, dass der entfernte Vorgang fehlgeschlagen ist. Er beweist nur, dass der Client gemäß seiner konfigurierten Regel nicht länger gewartet hat. Dieser Unterschied ist besonders bei zustandsändernden Anfragen wichtig. Der entfernte Server könnte eine Bestellung festgeschrieben oder eine Nachricht versendet haben, kurz bevor der Client die Verbindung verliert. Eine blinde Wiederholung kann dann genau den Effekt duplizieren, den sie retten sollte.

Verbindungszeit und Transferzeit beantworten verschiedene Fragen

libcurl stellt zwei hilfreiche Grenzen bereit:

  • CURLOPT_CONNECTTIMEOUT_MS begrenzt die Verbindungsphase.
  • CURLOPT_TIMEOUT_MS begrenzt den vollständigen Transferversuch.

Nach der libcurl-Dokumentation zum Verbindungs-Timeout umfasst die Verbindungsphase die DNS-Auflösung sowie die Protokoll-Handshakes und -Aushandlungen, die zum Aufbau der Verbindung erforderlich sind. Sobald die Verbindung steht, begrenzt dieser Wert nicht mehr die Dauer der Antwort.

Der Gesamt-Timeout wird nicht nach dem Verbindungs-Timeout addiert. Die Verbindungsphase liegt innerhalb der Gesamtgrenze. Beträgt die Verbindungsgrenze 1,5 Sekunden und die Gesamtgrenze 5 Sekunden, kann der vollständige Versuch nicht 6,5 Sekunden dauern: Die Verbindung darf höchstens 1,5 Sekunden beanspruchen, während der gesamte Transfer höchstens 5 Sekunden dauern darf.

Werte wie 1,5 und 5 Sekunden sind Beispiele und keine empfohlenen Standardwerte. Ein Export im Hintergrund darf berechtigterweise Minuten benötigen; eine interaktive Seite kann ein deutlich kleineres Latenzbudget haben. Die Werte sollten sich aus dem dokumentierten Verhalten des Endpunkts, der beobachteten Latenz, der für Benutzer relevanten Frist und den Kosten eines Abbruchs ergeben. Ein Timeout unterhalb der normalen Latenz erzeugt selbst verursachte Fehler, während ein sehr großer Wert PHP-Worker belegen kann, lange nachdem der Aufrufer nicht mehr wartet.

Ein Transportfehler ist keine HTTP-Antwort

Die PHP-Dokumentation zu curl_exec beschreibt einen leicht zu übersehenden Unterschied. Ist CURLOPT_RETURNTRANSFER aktiviert, gibt ein Transferfehler den booleschen Wert false zurück. Eine HTTP-Antwort wie 404 ist kein cURL-Transferfehler; die Anfrage kann auf Transportebene erfolgreich abgeschlossen worden sein und dennoch den Vertrag der Anwendung nicht erfüllen.

Damit hat der Client mindestens drei Ergebnisklassen:

  1. Transportfehler: Es liegt keine nutzbare HTTP-Antwort vor. curl_errno() und eine begrenzte Diagnosemeldung sollten erhalten bleiben. Die libcurl-Fehlerliste weist beispielsweise einem Vorgangs-Timeout den Code 28 und DNS-, Verbindungs-, TLS-, Sende- und Empfangsfehlern jeweils andere Codes zu.
  2. HTTP-Antwort außerhalb des akzeptierten Vertrags: Ein Status wie 401, 404, 429 oder 503 wurde empfangen. Diese Statuswerte rechtfertigen nicht alle dieselbe Reaktion.
  3. Akzeptierte HTTP-Antwort: Der Status ist zulässig, doch die Anwendung muss möglicherweise weiterhin Content-Type, Größe, JSON-Syntax und erforderliche Felder validieren.

Alle drei Fälle als „API fehlgeschlagen“ zusammenzufassen entfernt Informationen, die für die Wiederherstellung benötigt werden. Ungültige Zugangsdaten werden durch eine Wiederholung nicht korrekt. Fehlerhaftes JSON als Netzwerk-Timeout zu behandeln verbirgt ein Vertragsproblem. Jeden 2xx-Body als gültig anzusehen kann den Fehler lediglich tiefer in die Anwendung verschieben.

Zuerst entscheiden, ob der Vorgang wiederholbar ist

Eine Retry-Policy braucht zwei Freigaben: Der Fehler muss plausibel vorübergehend sein und der Vorgang muss sicher wiederholt werden können. Nur die erste Bedingung zu prüfen, kann eine Resilience-Funktion in einen Fehler mit doppelten Aktionen verwandeln.

RFC 9110 definiert idempotente Methoden als Methoden, deren beabsichtigte Wirkung bei mehreren identischen Anfragen dieselbe ist wie bei einer einzelnen Anfrage. PUT, DELETE und die sicheren Methoden sind gemäß HTTP-Semantik idempotent; POST ist nicht von sich aus idempotent. Der RFC erlaubt die automatische Wiederholung idempotenter Anfragen nach einem Verbindungsfehler und schränkt automatische Wiederholungen nicht idempotenter Anfragen stärker ein.

Methodennamen sind trotzdem keine Magie. Ein schlecht entworfener GET-Endpunkt könnte entgegen der HTTP-Semantik eine Aktion auslösen, während eine bestimmte POST-API eine sichere Wiederholung über einen dokumentierten Idempotency Key unterstützen kann. Vor der Wiederholung einer Mutation ist der Vertrag des Anbieters zu prüfen: Wie ist der Schlüssel abgegrenzt, wie lange wird er gespeichert und wird derselbe Schlüssel mit einem anderen Body abgewiesen? Fehlen diese Antworten, sollte ein mehrdeutiger Timeout normalerweise zu einem sichtbaren Zustand für die Abstimmung führen und nicht automatisch zu einem zweiten POST.

Bedingungen wie Preconditions, stabile Vorgangskennungen oder ein vom Anbieter unterstützter Idempotency Key können manche Vorgänge bedingt wiederholbar machen. Sie verringern Mehrdeutigkeit nur in dem Umfang, den das empfangende System garantiert. Ein beliebiger Header, den der Server ignoriert, bietet keinen Schutz.

Nur eine enge Auswahl vorübergehender Ergebnisse wiederholen

Bei einem bereits als wiederholbar eingestuften Vorgang können ausgewählte DNS- oder Verbindungsfehler, Timeouts und Serverantworten, die laut API-Dokumentation wiederholbar sind, vorübergehende Kandidaten sein. Die genaue Allowlist gehört zur jeweiligen Integration und nicht in ein universelles Snippet.

HTTP stellt nützliche Signale bereit, ohne zu versprechen, dass jeder Server sie verwendet. RFC 6585 definiert 429 Too Many Requests und erlaubt der Antwort ein Retry-After-Feld. RFC 9110 definiert dieses Feld entweder als HTTP-Datum oder als Anzahl von Sekunden. Ein Client, der es versteht, kann entsprechend warten, bleibt dabei aber an seine eigene maximale Wartezeit und Gesamtfrist gebunden. Der Server kann das Feld weglassen, und ein stark ausgelasteter Server muss nicht zwingend 429 senden.

Die meisten gewöhnlichen 4xx-Antworten sollten eine Prüfung statt einer Wiederholung auslösen: Fehlerhafte Eingaben, fehlgeschlagene Authentifizierung, unzureichende Berechtigungen oder eine nicht vorhandene Ressource erfordern normalerweise eine Änderung. Manche 5xx-Antworten können vorübergehend sein, doch die Sicherheit einer Wiederholung hängt weiterhin von der Semantik der Anfrage und der Dokumentation des Anbieters ab. Selbst eine übliche Liste wie 408, 429, 500, 502, 503 und 504 ist nur ein Ausgangspunkt für die Klassifizierung und keine Erlaubnis, jede Anfrage erneut abzuspielen.

Wiederholungen mit Backoff, Jitter und einer Frist begrenzen

Eine sofortige Wiederholung kann nach einer kurzen Verbindungsunterbrechung erfolgreich sein. Mehrere unmittelbare Wiederholungen können jedoch auch den Druck auf eine bereits beeinträchtigte Abhängigkeit erhöhen. Exponential Backoff vergrößert den Abstand zwischen späteren Versuchen. Jitter fügt zufällige Abweichungen hinzu, damit nicht viele Clients im selben Moment zurückkehren.

Backoff allein ist keine Begrenzung. Eine Policy benötigt außerdem eine maximale Versuchszahl, eine maximale Verzögerung und vorzugsweise eine Frist für den vollständigen Vorgang. Drei Versuche mit jeweils fünf Sekunden Timeout können bereits ungefähr fünfzehn Sekunden Transferzeit verbrauchen, zuzüglich Wartezeit, DNS und lokaler Verarbeitung. Ein Timeout pro Versuch beantwortet deshalb nicht die Frage: „Wie lange kann dieser Job einen Worker belegen?“

Der Retry-Leitfaden von Everything curl macht diesen Unterschied für das Kommandozeilenwerkzeug ausdrücklich sichtbar: Das Maximum pro Transfer und die Obergrenze der gesamten Wiederholungszeit sind getrennte Kontrollen. Anwendungscode braucht dieselbe konzeptionelle Trennung, selbst wenn er die Schleife selbst implementiert.

Ein begrenztes PHP-Beispiel für GET-Anfragen

Das folgende Beispiel unterstützt bewusst nur GET. Es trennt Transport- und HTTP-Ergebnisse, wiederholt eine enge Auswahl vorübergehender Ergebnisse, verwendet Full Jitter und beendet den Vorgang anhand einer Gesamtfrist. Seine Werte sind Beispiele und müssen für die tatsächliche API kalibriert werden.

<?php
function fetchGet(string $url): array
{
    $deadlineMs = (hrtime(true) / 1_000_000) + 12_000;
    $retryableCurl = [
        CURLE_COULDNT_RESOLVE_HOST,
        CURLE_COULDNT_CONNECT,
        CURLE_OPERATION_TIMEDOUT,
        CURLE_SEND_ERROR,
        CURLE_RECV_ERROR,
    ];
    $retryableHttp = [408, 429, 500, 502, 503, 504];

    for ($attempt = 1; $attempt <= 3; $attempt++) {
        $remainingMs = (int) floor($deadlineMs - (hrtime(true) / 1_000_000));
        if ($remainingMs <= 0) {
            throw new RuntimeException('HTTP operation deadline exceeded');
        }

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => min(1500, $remainingMs),
            CURLOPT_TIMEOUT_MS => min(5000, $remainingMs),
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
        ]);

        $body = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $duration = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
        curl_close($ch);

        if ($body !== false && $status >= 200 && $status < 300) {
            return ['status' => $status, 'body' => $body, 'seconds' => $duration];
        }

        $mayRetry = $body === false
            ? in_array($errno, $retryableCurl, true)
            : in_array($status, $retryableHttp, true);

        if (!$mayRetry || $attempt === 3) {
            $detail = $body === false ? "cURL {$errno}: {$error}" : "HTTP {$status}";
            throw new RuntimeException("Remote request failed: {$detail}");
        }

        $capMs = min(2000, 250 * (2 ** ($attempt - 1)));
        $sleepMs = random_int(0, $capMs);
        $remainingMs = (int) floor($deadlineMs - (hrtime(true) / 1_000_000));
        if ($sleepMs >= $remainingMs) {
            throw new RuntimeException('No time remains for another attempt');
        }
        usleep($sleepMs * 1000);
    }

    throw new LogicException('Unreachable retry state');
}

Dies ist ein Lehrbeispiel und kein direkt einsetzbarer HTTP-Client. Es wertet Retry-After nicht aus, begrenzt die Antwortgröße nicht, validiert die URL nicht, dekodiert kein JSON, behandelt keine Abbruchanforderung eines übergeordneten Requests und stellt keine Metriken bereit. Es setzt außerdem voraus, dass das GET-Ziel sichere HTTP-Semantik einhält. Ein gepflegtes SDK kann anbieterspezifische Retry- und Idempotency-Regeln bereits genauer implementieren; eine zusätzliche Retry-Schicht kann die Zahl der Versuche unbeabsichtigt vervielfachen.

Für POST oder eine andere geschäftliche Mutation ist eine andere Struktur sicherer: zuerst das Idempotenzprotokoll des Vorgangs festlegen, ausreichend lokalen Zustand zur Abstimmung eines mehrdeutigen Ergebnisses speichern und erst dann automatische Wiederholungen für ausdrücklich freigegebene Ergebnisse aktivieren.

Fehler sichtbar machen, ohne Geheimnisse offenzulegen

Wiederholungen können ein System für Benutzer ruhiger wirken lassen und zugleich seine Abhängigkeiten schwerer verständlich machen. Es sollte genügend strukturierter Kontext aufgezeichnet werden, um den Ablauf nachzuvollziehen: Name der Abhängigkeit, Vorgangsname, Nummer des Versuchs, Klasse des Endergebnisses, HTTP-Status oder cURL-Fehlercode, verstrichene Zeit sowie eine Korrelations- oder Vorgangskennung. Zugriffstoken, Authorization-Header, vollständige sensible Payloads oder unbegrenzte Antwort-Bodys gehören nicht in gewöhnliche Logs.

Nützliche Messwerte umfassen Anfragezahl, Latenz, Anzahl der Timeouts, gestartete und ausgeschöpfte Wiederholungen sowie Antworten nach grober Statusklasse. Sie beweisen nicht, warum eine Abhängigkeit ausgefallen ist, zeigen aber, ob die gewählten Grenzen zur Realität passen. Wenn fast jeder erfolgreiche Aufruf den letzten Versuch benötigt, verbirgt die Retry-Schleife möglicherweise einen ungeeigneten Timeout oder einen beeinträchtigten Dienst.

Tests sollten mehr als einen erfolgreichen 200-Pfad abdecken: verzögerten Verbindungsaufbau, verzögerte Antwort, DNS-Fehler, TLS-Fehler, fehlerhaften Inhalt, permanente 4xx-Antwort, vorübergehende Antwort mit und ohne Retry-After, ausgeschöpfte Frist und ein mehrdeutiges Mutationsergebnis. Ein Fake-Server oder kontrolliertes Test Double ist sicherer und reproduzierbarer, als darauf zu warten, dass eine Produktionsabhängigkeit zum passenden Zeitpunkt ausfällt.

Fazit

Zuverlässiges ausgehendes HTTP entsteht nicht durch eine Schleife um curl_exec. Es entsteht aus einer Folge engerer Entscheidungen: eine Verbindungsgrenze und ein Gesamtlimit pro Versuch setzen, Transportfehler von HTTP-Antworten unterscheiden, die Wiederholbarkeit des Vorgangs prüfen, nur freigegebene vorübergehende Ergebnisse wiederholen und den vollständigen Vorgang innerhalb eines bekannten Budgets beenden.

Diese Kontrollen können die Unsicherheit nach dem Timeout einer Mutation nicht beseitigen. Sie können jedoch verhindern, dass diese Unsicherheit hinter unbegrenztem Warten oder automatischen doppelten Anfragen verborgen wird. Die hilfreichste nächste Frage für jede Integration lautet daher nicht „Wie viele Wiederholungen braucht sie?“, sondern „Welches Ergebnis kann diese Anwendung sicher erkennen, wiederholen oder abstimmen?“

References