Zuletzt aktualisiert am 24. Juni 2026 von Caesar Fikson
Direkte Antwort: Ein zuverlässiger Webhook- oder Affiliate-Postback-Zustellungsdienst benötigt eine robuste Warteschlange, begrenzte Wiederholungsversuche mit Jitter, Circuit Breaking pro Endpunkt, Duplikatschutz, Signaturprüfung und Telemetrie, die anzeigt, ob sich die Zustellung verlangsamt, bevor sie fehlschlägt.
Diese Implementierung hält das Ausführungsmodell bewusst schlank: Python-Worker und PostgreSQL für Warteschlange, Zustellungshistorie und Endpunktstatus. Sie ist ein praktischer Ausgangspunkt für B2B-SaaS, iGaming-Affiliate-Management und jedes Produkt, das ausgehende Ereignisse liefern muss, ohne einen einzelnen HTTP-Timeout als verlorene Konversion zu werten.
Was der Liefervertrag garantieren muss
| Kontrolle | Warum es wichtig ist | Funktionsprüfung |
|---|---|---|
| Dauerhafte Ereignisaufzeichnung | Ereignisse bleiben auch nach Neustarts der Worker erhalten. | Jede angenommene Lieferung hat eine eindeutige ID und einen festen Status. |
| Unterzeichneter Antrag | Die Empfänger können den Absender verifizieren und Manipulationen am Körper erkennen. | Verwenden Sie eine mit einem Zeitstempel versehene Signatur und lehnen Sie veraltete Anfragen ab. |
| Duplikatschutz | Andernfalls können Wiederholungsversuche zu doppelten Konvertierungen oder Aktualisierungen führen. | Senden Sie eine Ereignis-ID und machen Sie den Empfänger idempotent. |
| Begrenzte Wiederholungsversuche | Vorübergehende Ausfälle werden behoben, ohne einen beeinträchtigten Endpunkt zu überlasten. | Bei nervösen Versuchen vorsichtig vorgehen; nach einer festgelegten Anzahl von Versuchen aufhören. |
| Endpunkttelemetrie | Die Warteschlangenlänge allein verschleiert langsame oder ausfallende Partner. | Erfolgsrate, ältestes ausstehendes Ereignis und Latenz-Perzentile pro Endpunkt verfolgen. |
Sicherheits- und Duplikatkontrollen haben Vorrang vor der Optimierung der Wiederholungsversuche.
Behandeln Sie den ausgehenden Anfragetext als sensible Betriebsdaten. Signieren Sie den Anfragetext exakt, fügen Sie einen Zustellungszeitstempel und eine unveränderliche Ereignis-ID hinzu, wechseln Sie die Signaturschlüssel regelmäßig und stellen Sie sicher, dass die empfangende Anwendung eine Wiederholung desselben Ereignisses zuverlässig ignoriert. Eine erfolgreiche HTTP-Antwort beweist nicht, dass ein Geschäftsereignis genau einmal angewendet wurde; diese Entscheidung muss die empfangende Seite treffen.
Bei Affiliate- oder iGaming-Postback-Workflows sollten Klick-IDs, Conversion-IDs und auszahlungsrelevante Felder nicht in den Protokollen gespeichert werden, es sei denn, Zugriffskontrollen und Aufbewahrungsregeln sind explizit festgelegt. Die unten stehende Scaleo-Referenz dient lediglich als Beispiel für die Latenz von Affiliate-Postbacks; sie ersetzt nicht die Dokumentation des Liefervertrags zwischen Ihren eigenen Diensten.
Nützliche Implementierungshinweise: Stripe-Webhook-Signaturprüfung, PostgreSQL SELECT und SKIP LOCKED und AWS-Leitfaden zu exponentiellem Backoff und Jitter.
Das Datenmodell
Alles beginnt mit zwei Tabellen: einer für die Lieferwarteschlange und einer für die Latenzmessungen.
SQL
-- Pending and in-flight webhook deliveries
CREATE TABLE webhook_queue (
id SERIAL PRIMARY KEY,
endpoint_id INTEGER NOT NULL,
endpoint_url TEXT NOT NULL,
payload JSONB NOT NULL,
-- Delivery state
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, in_flight, delivered, failed, dead_letter
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
-- Timing
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
next_attempt_at TIMESTAMP NOT NULL DEFAULT NOW(),
delivered_at TIMESTAMP,
last_error TEXT,
-- Response tracking
last_status_code INTEGER,
last_response_ms INTEGER, -- response time in milliseconds
INDEX idx_queue_next (status, next_attempt_at)
WHERE status IN ('pending', 'failed')
);
-- Per-endpoint latency measurements (ring buffer)
CREATE TABLE endpoint_latency (
id SERIAL PRIMARY KEY,
endpoint_id INTEGER NOT NULL,
response_ms INTEGER NOT NULL,
status_code INTEGER,
measured_at TIMESTAMP NOT NULL DEFAULT NOW(),
INDEX idx_latency_endpoint (endpoint_id, measured_at)
);
-- Endpoint health state (circuit breaker)
CREATE TABLE endpoint_health (
endpoint_id INTEGER PRIMARY KEY,
endpoint_url TEXT NOT NULL,
state VARCHAR(20) NOT NULL DEFAULT 'closed',
-- closed (healthy), open (broken), half_open (testing)
consecutive_failures INTEGER NOT NULL DEFAULT 0,
failure_threshold INTEGER NOT NULL DEFAULT 10,
last_failure_at TIMESTAMP,
last_success_at TIMESTAMP,
opened_at TIMESTAMP, -- when circuit opened
cooldown_seconds INTEGER NOT NULL DEFAULT 300, -- 5 min before half_open
-- Latency stats (updated periodically)
p50_ms INTEGER,
p95_ms INTEGER,
p99_ms INTEGER,
sample_count INTEGER NOT NULL DEFAULT 0
);
Drei Dinge sind bei diesem Schema zu beachten.
Zuerst wird der webhook_queue Die Tabelle verwendet eine next_attempt_at Anstelle eines separaten Scheduling-Mechanismus wird eine Spalte verwendet. Der Worker fragt Zeilen ab, in denen status IN ('pending', 'failed') AND next_attempt_at <= NOW()Dies ist eine einfache Verzögerungswarteschlange, die bis zu etwa 10,000 Zustellungen pro Minute gut funktioniert. Darüber hinaus sollte ein geeigneter Message Broker eingesetzt werden.
Zweitens, die endpoint_latency Die Tabelle dient als Ringpuffer. Ich lösche regelmäßig Zeilen, die älter als 24 Stunden sind. Die Latenz-Perzentile in endpoint_health werden aus diesem gleitenden Zeitfenster berechnet – sie stellen das aktuelle Verhalten dar, nicht historische Durchschnittswerte.
Drittens, die endpoint_health Die Tabelle implementiert den Zustandsautomaten des Leistungsschalters. Mehr dazu weiter unten.
Der Zusteller
Die Kernverarbeitungsschleife ist bewusst einfach gehalten. Komplexität entsteht durch die Wiederholungslogik und den Schutzmechanismus, nicht durch den Zustellungspfad selbst.
python
import requests
import time
import psycopg2
from psycopg2.extras import RealDictCursor
from datetime import datetime, timedelta
DB_DSN = "postgresql://user:pass@localhost/webhooks"
def get_connection():
return psycopg2.connect(DB_DSN)
def deliver_webhooks(batch_size=50):
"""
Fetch pending webhooks and attempt delivery.
Uses SELECT FOR UPDATE SKIP LOCKED for safe concurrent workers.
"""
conn = get_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute("""
SELECT id, endpoint_id, endpoint_url, payload,
attempt_count, max_attempts
FROM webhook_queue
WHERE status IN ('pending', 'failed')
AND next_attempt_at <= NOW()
ORDER BY next_attempt_at ASC
LIMIT %s
FOR UPDATE SKIP LOCKED
""", (batch_size,))
rows = cur.fetchall()
for row in rows:
# Check circuit breaker before attempting
if is_circuit_open(cur, row['endpoint_id']):
# Don't attempt delivery — reschedule for after cooldown
reschedule_for_cooldown(cur, row['id'], row['endpoint_id'])
continue
# Attempt delivery and measure latency
result = attempt_delivery(
row['endpoint_url'],
row['payload']
)
# Record latency measurement regardless of success/failure
record_latency(
cur,
row['endpoint_id'],
result['response_ms'],
result['status_code']
)
if result['success']:
mark_delivered(cur, row['id'], result)
record_success(cur, row['endpoint_id'])
else:
handle_failure(
cur, row['id'], row['endpoint_id'],
row['attempt_count'], row['max_attempts'],
result
)
conn.commit()
except Exception as e:
conn.rollback()
raise
finally:
cur.close()
conn.close()
def attempt_delivery(url, payload):
"""
Fire the webhook and measure response time.
Returns dict with success, status_code, response_ms, error.
"""
start = time.monotonic()
try:
response = requests.post(
url,
json=payload,
timeout=15, # 15 second hard timeout
headers={
'Content-Type': 'application/json',
'User-Agent': 'WebhookDelivery/1.0',
'X-Delivery-Timestamp': str(int(time.time()))
}
)
elapsed_ms = int((time.monotonic() - start) * 1000)
return {
'success': 200 <= response.status_code < 300,
'status_code': response.status_code,
'response_ms': elapsed_ms,
'error': None if response.ok else f"HTTP {response.status_code}"
}
except requests.Timeout:
elapsed_ms = int((time.monotonic() - start) * 1000)
return {
'success': False,
'status_code': None,
'response_ms': elapsed_ms,
'error': 'timeout_15s'
}
except requests.ConnectionError as e:
elapsed_ms = int((time.monotonic() - start) * 1000)
return {
'success': False,
'status_code': None,
'response_ms': elapsed_ms,
'error': f'connection_error: {str(e)[:200]}'
}
Das SELECT FOR UPDATE SKIP LOCKED Die Klausel ist entscheidend für den Betrieb mehrerer Worker-Instanzen. Ohne diese Klausel… SKIP LOCKEDZwei Worker würden in derselben Zeile blockieren. Dadurch erhält jeder Worker einen anderen Stapel ausstehender Webhooks. Dies ermöglicht horizontale Skalierung durch einfaches Starten weiterer Worker-Prozesse.
Das time.monotonic() Anrufen statt time.time() ist Absicht. time.time() Bei NTP-Anpassungen können Rücksprünge auftreten. time.monotonic() Es geht niemals rückwärts, was wichtig ist, wenn man Latenzzeiten im Subsekundenbereich misst.
Wiederholungslogik mit exponentiellem Backoff und Jitter
Wenn eine Zustellung fehlschlägt, entscheidet die Dauer der Wiederholungsversuche darüber, ob sich Ihr System problemlos erholt oder eine Flut von Anfragen auslöst, die einen ohnehin schon überlasteten Endpunkt überfordert.
python
import random
def calculate_next_attempt(attempt_count, base_delay=30, max_delay=3600):
"""
Exponential backoff with full jitter.
Attempt 1: 0-30s
Attempt 2: 0-60s
Attempt 3: 0-120s
Attempt 4: 0-240s
Attempt 5: 0-480s (capped at max_delay)
Full jitter prevents thundering herd when an endpoint
recovers and hundreds of retries fire simultaneously.
"""
exponential_delay = base_delay * (2 ** attempt_count)
capped_delay = min(exponential_delay, max_delay)
jittered_delay = random.uniform(0, capped_delay)
return datetime.utcnow() + timedelta(seconds=jittered_delay)
def handle_failure(cur, webhook_id, endpoint_id,
attempt_count, max_attempts, result):
"""
Handle a failed delivery attempt.
Either retry with backoff or move to dead letter queue.
"""
new_attempt_count = attempt_count + 1
if new_attempt_count >= max_attempts:
# Exhausted retries — dead letter
cur.execute("""
UPDATE webhook_queue
SET status = 'dead_letter',
attempt_count = %s,
last_error = %s,
last_status_code = %s,
last_response_ms = %s
WHERE id = %s
""", (
new_attempt_count, result['error'],
result['status_code'], result['response_ms'],
webhook_id
))
else:
# Schedule retry with backoff
next_attempt = calculate_next_attempt(new_attempt_count)
cur.execute("""
UPDATE webhook_queue
SET status = 'failed',
attempt_count = %s,
next_attempt_at = %s,
last_error = %s,
last_status_code = %s,
last_response_ms = %s
WHERE id = %s
""", (
new_attempt_count, next_attempt,
result['error'], result['status_code'],
result['response_ms'], webhook_id
))
# Update circuit breaker
record_failure(cur, endpoint_id)
Warum Full Jitter anstelle von Decorrelated Jitter oder Equal Jitter? AWS hat hierzu die maßgebliche Analyse veröffentlicht. Full Jitter (Zufallsvariation zwischen 0 und dem exponentiellen Maximalwert) führt zur kürzesten Gesamtverarbeitungszeit über alle Clients hinweg. Equal Jitter (Zufallsvariation zwischen der Hälfte und dem vollen Maximalwert) ist konservativer, baut den Wiederholungs-Backlog aber langsamer ab. Bei der Webhook-Zustellung mit vielen unabhängigen Endpunkten ist Full Jitter die richtige Wahl, da die Wiederholungsversuche jedes Endpunkts unabhängig sind – eine Koordination zwischen ihnen entfällt.
Schutzschalter: Hören Sie auf, defekte Endpunkte zu bearbeiten.
Das Schutzmechanismus-Muster verhindert, dass Ihr System Ressourcen für Endpunkte verschwendet, die ständig fehlschlagen. Ohne dieses Muster häufen sich bei einem nicht funktionierenden Endpunkt Hunderte von ausstehenden Wiederholungsversuchen an, die alle nach 15 Sekunden ein Timeout verursachen – wodurch Ihre Worker-Kapazität für Zustellungen verbraucht wird, die niemals erfolgreich sein werden.
python
def is_circuit_open(cur, endpoint_id):
"""
Check if the circuit breaker is open (endpoint is broken).
If open and cooldown has passed, transition to half_open.
"""
cur.execute("""
SELECT state, opened_at, cooldown_seconds
FROM endpoint_health
WHERE endpoint_id = %s
""", (endpoint_id,))
row = cur.fetchone()
if not row:
return False # no health record = assume healthy
if row['state'] == 'closed':
return False
if row['state'] == 'open':
# Check if cooldown period has elapsed
if row['opened_at'] and row['cooldown_seconds']:
elapsed = (datetime.utcnow() - row['opened_at']).total_seconds()
if elapsed >= row['cooldown_seconds']:
# Transition to half_open — allow one probe
cur.execute("""
UPDATE endpoint_health
SET state = 'half_open'
WHERE endpoint_id = %s
""", (endpoint_id,))
return False # allow the probe delivery
return True # still in cooldown
if row['state'] == 'half_open':
return False # allow probe delivery
return False
def record_failure(cur, endpoint_id):
"""
Record a delivery failure. Open circuit if threshold reached.
"""
cur.execute("""
UPDATE endpoint_health
SET consecutive_failures = consecutive_failures + 1,
last_failure_at = NOW()
WHERE endpoint_id = %s
RETURNING consecutive_failures, failure_threshold, state
""", (endpoint_id,))
row = cur.fetchone()
if not row:
# Create health record on first failure
cur.execute("""
INSERT INTO endpoint_health (endpoint_id, endpoint_url,
consecutive_failures, last_failure_at)
VALUES (%s, '', 1, NOW())
ON CONFLICT (endpoint_id) DO UPDATE
SET consecutive_failures = endpoint_health.consecutive_failures + 1,
last_failure_at = NOW()
""", (endpoint_id,))
return
if row['state'] == 'half_open':
# Probe failed — re-open circuit with longer cooldown
cur.execute("""
UPDATE endpoint_health
SET state = 'open',
opened_at = NOW(),
cooldown_seconds = LEAST(cooldown_seconds * 2, 3600)
WHERE endpoint_id = %s
""", (endpoint_id,))
elif (row['state'] == 'closed' and
row['consecutive_failures'] >= row['failure_threshold']):
# Threshold reached — open circuit
cur.execute("""
UPDATE endpoint_health
SET state = 'open',
opened_at = NOW(),
cooldown_seconds = 300 -- reset to 5 minutes
WHERE endpoint_id = %s
""", (endpoint_id,))
def record_success(cur, endpoint_id):
"""
Record a delivery success. Close circuit if half_open.
"""
cur.execute("""
UPDATE endpoint_health
SET consecutive_failures = 0,
last_success_at = NOW(),
state = 'closed'
WHERE endpoint_id = %s
""", (endpoint_id,))
Die Eskalation von halb geöffnet zu geöffnet mit verdoppelter Abklingzeit ist ein Detail, das die meisten Implementierungen übersehen. Schlägt ein Endpunkt während der Prüfung fehl (Zustand halb geöffnet), sollte er nicht erst nach weiteren 5 Minuten erneut versucht werden. Der Endpunkt ist weiterhin defekt. Verdoppeln Sie die Abklingzeit auf 10 Minuten, dann auf 20 Minuten und maximal auf 1 Stunde. Dadurch wird verhindert, dass der Schutzmechanismus zu einem permanenten Auslösemechanismus wird.
Latenz-Perzentil-Tracking
Durchschnittswerte täuschen. Ein Endpunkt mit einer durchschnittlichen Reaktionszeit von 200 ms reagiert möglicherweise in 95 % der Fälle in 50 ms und in den restlichen 5 % in 3,000 ms. Der Durchschnittswert sieht gut aus. Der P95-Wert deckt jedoch ein Problem auf, das jede 20. Zustellung betrifft.
python
def record_latency(cur, endpoint_id, response_ms, status_code):
"""
Record a latency measurement and update percentile stats.
"""
cur.execute("""
INSERT INTO endpoint_latency
(endpoint_id, response_ms, status_code)
VALUES (%s, %s, %s)
""", (endpoint_id, response_ms, status_code))
def update_latency_percentiles(cur, endpoint_id, window_hours=24):
"""
Calculate P50, P95, P99 from the rolling window.
Uses PostgreSQL's percentile_cont for exact percentiles.
"""
cur.execute("""
SELECT
COUNT(*) as sample_count,
percentile_cont(0.50) WITHIN GROUP
(ORDER BY response_ms) AS p50,
percentile_cont(0.95) WITHIN GROUP
(ORDER BY response_ms) AS p95,
percentile_cont(0.99) WITHIN GROUP
(ORDER BY response_ms) AS p99
FROM endpoint_latency
WHERE endpoint_id = %s
AND measured_at >= NOW() - INTERVAL '%s hours'
""", (endpoint_id, window_hours))
row = cur.fetchone()
if row and row['sample_count'] > 0:
cur.execute("""
UPDATE endpoint_health
SET p50_ms = %s,
p95_ms = %s,
p99_ms = %s,
sample_count = %s
WHERE endpoint_id = %s
""", (
int(row['p50']), int(row['p95']),
int(row['p99']), row['sample_count'],
endpoint_id
))
return row
def get_slow_endpoints(cur, p95_threshold_ms=2000):
"""
Find endpoints whose P95 latency exceeds the threshold.
These are candidates for investigation or circuit opening.
"""
cur.execute("""
SELECT endpoint_id, endpoint_url,
p50_ms, p95_ms, p99_ms, sample_count,
state, consecutive_failures
FROM endpoint_health
WHERE p95_ms > %s
AND sample_count >= 20 -- need sufficient samples
ORDER BY p95_ms DESC
""", (p95_threshold_ms,))
return cur.fetchall()
PostgreSQL percentile_cont ist eine Aggregatfunktion für geordnete Mengen, die exakte Perzentile berechnet. Bei großen Datensätzen würde man zu wechseln. percentile_disc (wodurch ein tatsächlicher Messwert anstelle einer Interpolation zurückgegeben wird) oder eine t-Digest-Approximation verwendet wird. Bei Webhook-Übermittlung mit einem 24-Stunden-Fenster sind exakte Perzentile der Rohdaten bis zu etwa 100,000 Messungen pro Endpunkt schnell genug.
Das get_slow_endpoints Diese Funktion führe ich alle 15 Minuten als geplante Überprüfung aus. Endpunkte mit einem P95-Wert über 2 Sekunden werden zur Untersuchung markiert. Bei Endpunkten mit einem P95-Wert über 5 Sekunden wird der Schwellenwert für den Schutzschalter reduziert – sie dürfen weniger aufeinanderfolgende Fehler verkraften, bevor der Schutzschalter eingreift, da jeder fehlgeschlagene Zustellungsvorgang einen Worker-Thread für die gesamte Timeout-Dauer blockiert.
Überwachung des Webhook-Zustellungsstatus
Hier ist die Überwachungsabfrage, die ich alle fünf Minuten ausführe. Sie erzeugt eine einzeilige Zusammenfassung des Zustands der gesamten Bereitstellungspipeline:
SQL
SELECT
-- Queue depth
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE status = 'failed') AS awaiting_retry,
COUNT(*) FILTER (WHERE status = 'in_flight') AS in_flight,
COUNT(*) FILTER (WHERE status = 'dead_letter') AS dead_letter,
-- Delivery rate (last hour)
COUNT(*) FILTER (
WHERE status = 'delivered'
AND delivered_at >= NOW() - INTERVAL '1 hour'
) AS delivered_last_hour,
-- Failure rate (last hour)
COUNT(*) FILTER (
WHERE status IN ('failed', 'dead_letter')
AND created_at >= NOW() - INTERVAL '1 hour'
) AS failed_last_hour,
-- Oldest undelivered
MIN(created_at) FILTER (
WHERE status IN ('pending', 'failed')
) AS oldest_pending,
-- Average delivery latency (last hour, successful only)
AVG(last_response_ms) FILTER (
WHERE status = 'delivered'
AND delivered_at >= NOW() - INTERVAL '1 hour'
) AS avg_delivery_ms_last_hour
FROM webhook_queue;
Das oldest_pending Der Wert ist die wichtigste Kennzahl in dieser Abfrage. Wenn er älter ist als Ihr maximales Wiederholungsfenster (Summe aller Backoff-Verzögerungen), liegt ein strukturelles Problem vor – entweder hängt der Worker fest, der Endpunkt ist nicht erreichbar oder die Warteschlange wächst schneller, als Sie sie leeren können.
Ich alarmiere bei drei Bedingungen: steigende Anzahl unzustellbarer Nachrichten (Endpunkte fallen dauerhaft aus und niemand untersucht das Problem), Überschreitung der Wartezeit in der Warteschlange von 30 Minuten (Zustellung verzögert sich), und Die P95-Latenz pro Endpunkt überschreitet Schwellenwerte, die auf eine verminderte Übertragungszuverlässigkeit hinweisen.Das dritte Signal ist ein Frühwarnzeichen – die Latenz steigt, bevor es zu Ausfällen kommt. Ein Endpunkt, der zuvor in 200 ms reagierte und nun 3 Sekunden benötigt, steht kurz vor einem Timeout.
Die Dead-Letter-Warteschlange ist nicht nur ein Speicherort.
Die meisten Teams verwenden eine Warteschlange für unzustellbare Nachrichten (Dead Letter Queue) als Tabelle, in der fehlgeschlagene Webhooks gelöscht werden. Diese wird nur gelegentlich im Rahmen der Reaktion auf Sicherheitsvorfälle überprüft. Das ist jedoch ineffizient.
Die Warteschlange für unzustellbare Nachrichten ist Ihr wertvollster Datensatz für die Fehlersuche. Jede Zeile repräsentiert eine Zustellung, die Ihr System mehrfach versucht, aber schließlich abgebrochen hat. Das Muster der unzustellbaren Nachrichten liefert Ihnen Informationen, die die Erfolgsmetriken niemals liefern können.
python
def analyze_dead_letters(cur, hours=24):
"""
Analyze recent dead letter entries for patterns.
Returns per-endpoint failure analysis.
"""
cur.execute("""
SELECT
endpoint_id,
endpoint_url,
COUNT(*) AS dead_count,
-- Most common error
MODE() WITHIN GROUP (ORDER BY last_error) AS primary_error,
-- Most common status code
MODE() WITHIN GROUP (ORDER BY last_status_code)
AS primary_status_code,
-- Timing
MIN(created_at) AS first_dead,
MAX(created_at) AS last_dead,
-- Average attempts before giving up
AVG(attempt_count)::INTEGER AS avg_attempts
FROM webhook_queue
WHERE status = 'dead_letter'
AND created_at >= NOW() - INTERVAL '%s hours'
GROUP BY endpoint_id, endpoint_url
ORDER BY dead_count DESC
LIMIT 20
""", (hours,))
return cur.fetchall()
Bei der Durchsicht toter Briefe suche ich nach drei Mustern.
Clusterausfälle: 50 unzustellbare Nachrichten für denselben Endpunkt innerhalb derselben Stunde bedeuten, dass der Endpunkt ausgefallen ist und sich innerhalb des Wiederholungsfensters nicht erholt hat. Maßnahme: Wiederholungsfenster verlängern oder manuelle Wiedereinreihung in die Warteschlange implementieren.
Statuscode-Muster: Ein plötzlicher Anstieg der 401/403-Fehler (Dead Letters) bedeutet, dass die Anmeldeinformationen des Endpunkts geändert wurden und die Webhook-Konfiguration nicht aktualisiert wurde. Ein plötzlicher Anstieg der 429-Fehler (Zu viele Anfragen) bedeutet, dass Sie das Ratenlimit überschreiten und die Anfragerate drosseln müssen.
Allmähliche Anhäufung: 2-3 Dead Letters pro Tag für einen einzelnen Endpunkt, gleichmäßig verteilt. Dies ist das heimtückischste Muster: Der Endpunkt funktioniert größtenteils, weist aber sporadische Fehler auf, die mit der Zeit die Wiederholungsversuche erschöpfen. Die Lösung besteht üblicherweise in einer Erhöhung der Anzahl der Wiederholungsversuche. max_attempts für diesen spezifischen Endpunkt oder zur Reduzierung des Timeouts.
Ausführen des Workers
Die zentrale Schleife, die alles miteinander verbindet:
python
import signal
import sys
running = True
def shutdown_handler(signum, frame):
global running
running = False
print(f"Received signal {signum}, shutting down gracefully...")
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
def main():
print("Webhook delivery worker starting...")
while running:
try:
deliver_webhooks(batch_size=50)
except Exception as e:
print(f"Worker error: {e}")
time.sleep(5) # back off on errors
continue
# Update latency stats every 100 iterations
# (cheap operation, doesn't need to run every loop)
if int(time.time()) % 100 == 0:
conn = get_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT DISTINCT endpoint_id FROM endpoint_health"
)
for row in cur.fetchall():
update_latency_percentiles(cur, row['endpoint_id'])
conn.commit()
finally:
cur.close()
conn.close()
# Poll interval — 500ms keeps latency low without
# hammering the database
time.sleep(0.5)
print("Worker shut down cleanly.")
if __name__ == '__main__':
main()
Das SIGTERM Der Handler ist für ein sauberes Herunterfahren in containerisierten Umgebungen unerlässlich. Wenn Kubernetes das SIGTERM-Signal sendet, beendet der Worker seinen aktuellen Batch, führt die Transaktion aus und beendet sich. Ohne diesen Handler bleiben Zeilen im System hängen. in_flight Status ohne Bearbeitung durch einen Mitarbeiter.
Was dieses System nicht leistet (und wann Sie mehr benötigen)
Diese Implementierung verarbeitet bis zu etwa 10,000 Lieferungen pro Minute auf einer einzelnen PostgreSQL-Instanz mit 2-3 Worker-Prozessen. Darüber hinaus sind drei Änderungen erforderlich.
Ersetzen Sie zunächst die PostgreSQL-Warteschlange durch Redis Streams oder RabbitMQ. SELECT FOR UPDATE SKIP LOCKED Dieses Muster erzeugt bei hohem Durchsatz Schreibkonflikte in der Warteschlangentabelle. Ein dedizierter Message Broker beseitigt dies.
Zweitens sollten Sie die Anfragerate pro Endpunkt begrenzen. Einige empfangende Endpunkte haben bereits Limits (100 Anfragen pro Minute, 1,000 pro Stunde). Ohne clientseitige Begrenzung überschreiten Sie deren Kontingent und erhalten einen 429-Fehler. Implementieren Sie daher einen Token-Bucket pro Endpunkt.
Drittens: Fügen Sie die Anforderungssignatur hinzu. HMAC-SHA256-Signaturen der Nutzdaten ermöglichen es dem empfangenden Endpunkt zu überprüfen, ob der Webhook von Ihrem System stammt und während der Übertragung nicht manipuliert wurde. Dies ist eine Grundvoraussetzung für jedes Webhook-System, das Finanzdaten sendet.
Das in diesem Artikel beschriebene System bildet die Grundlage. Es bewältigt die komplexen Probleme – Wiederholungslogik, Circuit Breaking, Latenzmessung, Analyse unzustellbarer Nachrichten –, die jedes Webhook-Zustellungssystem unabhängig von seiner Größe benötigt. Die spezifischen Komponenten, die Sie hinzufügen (Message Broker, Rate Limiter, Request Signing), hängen von Ihren Durchsatz- und Sicherheitsanforderungen ab.
Der wichtigste Aspekt ist der, den die meisten Teams vernachlässigen: die Messung des Auslieferungssystems selbst. Wer die Frage „Wie hoch ist die P95-Latenzzeit der Auslieferung an Endpunkt X in den letzten 24 Stunden?“ nicht beantworten kann, agiert im Blindflug. Zuerst muss die Messtechnik entwickelt werden. Alles Weitere ergibt sich daraus.
Häufig gestellte Fragen zur Webhook-Zustellung
Sollte der Absender eines Webhooks eine genau einmalige Zustellung garantieren?
Normalerweise nicht. Der Absender sollte Wiederholungsversuche sichtbar machen und eine stabile Ereignis-ID bereitstellen; der Empfänger sollte die Verarbeitung idempotent gestalten, damit ein Ereignis mehrfach zugestellt werden kann, ohne das Geschäftsergebnis zu duplizieren.
Welche Fehler sollten wiederholt werden?
Wiederholen Sie nur Fehler, die Ihr Vertrag als vorübergehend einstuft, wie z. B. Netzwerkfehler, Timeouts und bestimmte Serverantworten. Wiederholen Sie fehlerhafte Anfragen, Authentifizierungsfehler oder andere dauerhafte Fehler nicht wiederholt, ohne einen expliziten Lösungsweg vorzusehen.
Wann sollte ein Team von einer datenbankgestützten Warteschlange abweichen?
Verschieben Sie die Kapazität, wenn gemessene Auslastungsquoten, das Alter des Backlogs, der Durchsatz oder der Bedarf an operativer Wiederherstellung zeigen, dass die Datenbankwarteschlange die Liefervereinbarung nicht mehr erfüllt. Kapazitätsentscheidungen sollten sich an der beobachteten Arbeitslast orientieren und nicht an einer allgemeinen Angabe zur Anforderungsrate.