Guzzle 8.0 ist draußen — die Release-Artikel kamen um den 21. Juli 2026 herum — und das Feature, mit dem alle aufmachen, ist HTTP/3. Geschenkt. Es spart bei gesprächigen Integrationen etwas Handshake-Latenz und macht sich gut im Changelog. Die Änderung, die deine Rufbereitschaftswoche wirklich verändert, ist aber die langweiligere: Fehler lassen sich jetzt befragen. Du kannst fragen, was gestorben ist und wo, statt einen einzigen Exception-Typ zu bekommen, der so viel heißt wie "das Internet ist passiert". Dafür lohnt es sich, einen Sprint einzuplanen. Und trotzdem bleibt meine These: Es rettet dich nicht, denn der schwierige Teil eines fehlgeschlagenen HTTP-Calls war nie die Diagnose. Der schwierige Teil war die Entscheidung, was als Nächstes passiert — und die hat nie in der Client-Library gewohnt.

Nimm das Beispiel, das in diesen Diskussionen immer wieder auftaucht, weil es tatsächlich Geld kostet: Dein Service schickt einen POST an einen Payment-Provider, um eine Order anzulegen, und der Call läuft in einen Timeout. Zwei Welten sind mit diesem Timeout vereinbar. In der einen kam die TCP-Verbindung nie zustande, auf der Gegenseite ist nichts passiert. In der anderen hat der Provider deinen Request bekommen, die Order geschrieben, die Karte belastet — und die Antwort ist auf dem Rückweg gestorben. Alter Code hat eine breite RequestException gefangen, mit den Schultern gezuckt und retryt. In Welt eins ist das richtig. In Welt zwei hast du gerade jemandem doppelt abgebucht, und dein Support-Postfach erfährt es vor deinem Dashboard.

Guzzle 8 hilft hier wirklich, und ich will das nicht kleinreden. Zu wissen, dass eine Verbindung abgelehnt wurde, bevor auch nur ein Byte des Request-Bodys rausging, ist kategorisch etwas anderes, als zu wissen, dass du alles gesendet hast und der Read in Sekunde 29 in den Timeout lief. Das Erste ist unzweideutig sicher zu wiederholen. Das Zweite ist ein Münzwurf, den du niemals werfen solltest. Diese beiden Fälle im catch-Block unterscheiden zu können — statt curl-Fehlercodes aus einem String zu grepen, was die meisten von uns bisher gemacht haben — macht aus einer Vermutung eine Entscheidung. Das ist echt, und deshalb würde ich die Diagnostik nehmen, wenn mich jemand zwingt, mich zwischen ihr und HTTP/3 zu entscheiden.

Das ehrliche Gegenargument lautet, dass ich die Transport-Arbeit unterschätze. HTTP/3 ist nicht für alle Kosmetik. Wenn du aus einem Worker heraus Hunderte kleiner Calls an einen AI-Provider oder eine Maps-API fächerst, noch dazu über ein verlustbehaftetes Netz, dann ist Head-of-Line-Blocking eine messbare Steuer, und sie verschwinden zu lassen zeigt sich in Queue-Workern, die ihren Batch fertigmachen, statt mit vollem Connection-Pool herumzusitzen. Dasselbe gilt für die leiseren Punkte im Release: keine Credentials mehr in Logs und Redirects zu lecken, und nicht stillschweigend irgendeinen Kompatibilitäts-Fixup anzuwenden, um den du nie gebeten hast. Das sind die Defaults, die einen Incident verhindern, den du sonst nie zu deinem HTTP-Client zurückverfolgt hättest. Ich nenne das nicht Deko.

Aber schau, was dir eine reichere Exception-Hierarchie tatsächlich gibt. Sie sagt dir die Form des Fehlers. Sie kann dir nicht sagen, ob der entfernte Endpoint idempotent ist, weil sie keine Ahnung hat, was der Endpoint tut. Ein Read-Timeout auf GET /v1/orders/42 und ein Read-Timeout auf POST /v1/charges sind auf der Transportebene dasselbe Ereignis und fachlich zwei völlig verschiedene. Wenn deine Retry-Policy in einer Middleware wohnt, die nur den Transport sieht, wird sie irgendwann einen der beiden Fälle falsch behandeln. Der einzig dauerhafte Fix liegt auf der Protokollebene zwischen dir und deinem Provider: Idempotency-Keys, die der Aufrufer erzeugt, eine Outbox-Zeile, die geschrieben wird, bevor der Request rausgeht, und ein Reconciliation-Job, der den Provider fragt, was aus seiner Sicht passiert ist, statt zu raten.

Das Upgrade, das ich dir wirklich empfehle, hat also zwei Teile, und der zweite steht nicht in der composer.json. Erstens: Wechsle auf Guzzle 8 und lösche deine Catch-all-Handler — jede Stelle, an der du catch (RequestException $e) geschrieben und danach dreimal mit einem sleep retryt hast, ist jetzt eine Stelle, an der du eine begründbare Entscheidung treffen kannst. Zweitens, und das ist der Teil, der echte Zeit kostet: Sortiere deine ausgehenden Calls in zwei Listen — die, die man gefahrlos zweimal schicken darf, und die anderen. Die zweite Liste ist kürzer, als du denkst, und jeder Eintrag darauf braucht einen Idempotency-Key oder einen Reconciliation-Pfad. Mach diese Arbeit, und das Library-Upgrade verzinst sich. Lass sie aus, und du hast dir deutlich bessere Logs über dieselbe Doppelbuchung gekauft.

Woran ich mich immer wieder aufhänge, ist die Frage, wo diese Retry-Entscheidung physisch leben soll. Ich habe sie als Client-Middleware gebaut: aufgeräumt, wiederverwendbar und völlig blind dafür, was der Request bedeutet. Ich habe sie auch in der Service-Klasse direkt neben der Domänenlogik gebaut: schlau, korrekt und in elf Stellen kopiert. Nichts davon hat sich richtig angefühlt. Wo ziehst du die Linie — entstehen deine Idempotency-Keys in der HTTP-Schicht oder in der Domäne, und wie verhinderst du, dass die beiden auseinanderdriften?