webhook-gateway — belastbare Webhook-Annahme und -Zustellung

Ein Gateway, das eingehende Webhooks signaturprüft, dedupliziert und speichert und sie mit Retries, Circuit Breaking und Dead-Letter-Queue zustellt — mit PostgreSQL als Warteschlange.

Ergebnis

154 Tests, alle gegen ein echtes PostgreSQL — und ein Benchmark, dessen unvorteilhafteste Zeile genau die ist, die erklärt wird.

Das Problem

Ein Webhook von einem Drittanbieter kommt genau einmal. Geht er zwischen Socket und Datenbank verloren, kann niemand Stripe oder GitHub bitten, ihn erneut zu schicken — und der Fehler ist still, denn der Produzent hat sein 200 bekommen. Auf der anderen Seite steht ein Subscriber, den man nicht kontrolliert: Er läuft in einen Timeout, antwortet mit 500, ist eine Stunde weg, oder antwortet mit 400, weil sich der Vertrag geändert hat. Jeder dieser Fälle braucht eine andere Antwort, und „alles mit exponentiellem Backoff wiederholen" ist für die meisten davon die falsche.

Die Zusage ist ehrlich formuliert, weil nur die ehrliche Version brauchbar ist: genau einmal an der Persistenzgrenze, mindestens einmal zum Subscriber. Die erste Hälfte erzwingt ein Unique-Constraint, bewiesen von einem Nebenläufigkeitstest. Die zweite Hälfte kann niemand erzwingen — der letzte Schritt ist ein HTTP-Request an eine fremde Maschine, und ein Worker, der zwischen deren 200 und dem eigenen Commit stirbt, muss es erneut versuchen statt zu raten. Jeder ausgehende Request trägt deshalb einen stabilen Idempotency-Key und eine Versuchsnummer — genau das, was ein Subscriber braucht, um die Lücke auf seiner Seite zu schließen.

PostgreSQL ist die Warteschlange, damit die Warteschlange und die Daten, auf die sie sich bezieht, in einer Transaktion liegen. Ein Fan-out kann kein Ereignis committen, dessen Arbeitsauftrag verloren ging, ein Claim überlebt keinen zurückgerollten Versuch, und es gibt keinen Abgleichjob zwischen zwei Speichern, die sich widersprechen. Ein Broker brächte sprachübergreifendes Fan-out und kostete genau die Eigenschaft, für die es diesen Dienst gibt. FOR UPDATE SKIP LOCKED lässt N Worker disjunkte Batches ohne Koordinator übernehmen, und ein Lease sorgt dafür, dass die Zeilen eines abgestürzten Workers zurückgeholt statt liegen gelassen werden.

Jeder Fehlerfall hat eine durchdachte Antwort statt eines generischen Retrys. Ein Duplikat ist 200 mit duplicate: true — kein 409, das den Produzenten härter wiederholen lässt und jemanden aus dem Bett klingelt. Ein 400 vom Subscriber landet sofort im Dead Letter, denn acht identische Ablehnungen bringen niemandem etwas. Ein 429 mit Retry-After wird respektiert, denn ein Subscriber, der sagt, wann er wieder kann, weiß es besser als unsere Kurve. Backoff ist Full Jitter statt „exponentiell plus Rauschen", das die ganze Herde auf einen Zeitpunkt synchronisiert und den gerade genesenen Endpunkt erneut umbringt. Ein Circuit öffnet nach N Fehlern und verschiebt Zustellungen, ohne einen Versuch zu verbrauchen — ein 30-Sekunden-Ausfall soll kein Budget von acht Versuchen für nie gesendete Requests aufbrauchen. Im Halb-offen-Zustand wird genau eine Probe zugelassen, sonst stürmt der gesamte Rückstau den Endpunkt in dem Moment, in dem er zurückkommt.

Beim Ausliefern ist ein Gateway von Natur aus ein Confused Deputy: Jede aufgelöste Adresse wird geprüft, und die Verbindung wird anschließend auf die geprüfte Adresse festgenagelt, mit erhaltenem Host und TLS-SNI. Eine DNS-Antwort zu prüfen und den Client danach erneut auflösen zu lassen, ist ein TOCTOU-Fenster — und 169.254.169.254 gibt Cloud-Zugangsdaten heraus.

Der zentrale Test feuert 24 wirklich gleichzeitige, identische Webhooks — getrennte Sessions, getrennte Verbindungen, getrennte Backends im Wettlauf um ein Constraint — und prüft: ein Ereignis, ein Gewinner, eine Zustellung. Alle Tests laufen gegen ein echtes PostgreSQL, denn die Garantien sind die von PostgreSQL; eine Suite, die gegen SQLite grün wird, prüft eine Fiktion. Das Lastwerkzeug fand zwei echte Fehler, darunter einen Fail-open-Pfad, der pro Request ein Redis-Connect-Timeout bezahlte. Die Benchmark-Tabelle nennt die Maschine und die unvorteilhaften Zeilen mit.