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.
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.