system-prompts-and-models-o.../dealix/auto_client_acquisition/ecosystem/webhook_dispatcher.py
2026-05-01 14:03:52 +03:00

343 lines
10 KiB
Python

"""
Outbound webhook dispatcher — Scale tier ecosystem play.
Customers register webhook endpoints, we POST events with HMAC signing.
Each event is signed with HMAC-SHA256(secret, payload) — verifiable on
receipt without sharing the secret.
Pure functions: building, signing, retry policy decisions.
Network I/O is pluggable via a `transport` callable so this module is testable
without httpx in the import path.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
# ── Event taxonomy — what Dealix emits to subscribed customers ─────
EVENT_TYPES: tuple[str, ...] = (
"lead.created",
"lead.qualified",
"lead.disqualified",
"lead.enriched",
"draft.created",
"draft.approved",
"draft.sent",
"reply.received",
"reply.classified",
"demo.booked",
"demo.held",
"deal.created",
"deal.won",
"deal.lost",
"payment.received",
"health.changed",
"churn.predicted",
"qbr.generated",
"pulse.published",
)
@dataclass(frozen=True)
class WebhookSubscription:
"""A customer's registered webhook endpoint."""
customer_id: str
endpoint_url: str
secret: str # used to sign payloads — customer verifies on receipt
events: tuple[str, ...] = () # empty = subscribe to all
enabled: bool = True
created_at: int = 0 # unix ts
@dataclass
class WebhookDelivery:
"""Single delivery attempt record (immutable per attempt)."""
delivery_id: str
event_id: str
event_type: str
customer_id: str
endpoint_url: str
attempt: int
status_code: int | None = None
success: bool = False
error: str | None = None
duration_ms: int | None = None
timestamp: int = 0
request_signature: str = ""
@dataclass
class WebhookEvent:
"""Outbound event envelope."""
event_id: str
event_type: str
customer_id: str
payload: dict[str, Any]
timestamp: int
api_version: str = "v1"
def envelope(self) -> dict[str, Any]:
return {
"id": self.event_id,
"type": self.event_type,
"customer_id": self.customer_id,
"timestamp": self.timestamp,
"api_version": self.api_version,
"data": self.payload,
}
# ── Helpers ────────────────────────────────────────────────────────
def make_event(
*,
event_type: str,
customer_id: str,
payload: dict[str, Any],
now: int | None = None,
) -> WebhookEvent:
"""Build a new event envelope with a deterministic id."""
ts = now or int(time.time())
eid = f"evt_{uuid.uuid4().hex[:24]}"
return WebhookEvent(
event_id=eid,
event_type=event_type,
customer_id=customer_id,
payload=payload,
timestamp=ts,
)
def sign_payload(*, secret: str, body: bytes, timestamp: int) -> str:
"""
Compute HMAC-SHA256 signature in Stripe-like format:
t=<unix_ts>,v1=<hex_hmac>
Customer side verifies by recomputing HMAC over `f"{t}.{body}"`.
Replay window: customer should reject if |now - t| > 5 minutes.
"""
msg = f"{timestamp}.".encode() + body
digest = hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
return f"t={timestamp},v1={digest}"
def serialize_envelope(event: WebhookEvent) -> bytes:
"""Stable JSON serialization for signing."""
return json.dumps(event.envelope(), separators=(",", ":"), sort_keys=True).encode("utf-8")
# ── Retry policy — exponential backoff with jitter cap ─────────────
RETRY_SCHEDULE_SECONDS: tuple[int, ...] = (
0, # immediate
30, # 30s later
300, # 5min
1800, # 30min
21600, # 6h
86400, # 24h — last attempt
)
MAX_ATTEMPTS = len(RETRY_SCHEDULE_SECONDS)
def next_retry_delay(attempt: int) -> int | None:
"""Return seconds until next retry, or None if exhausted."""
if attempt < 0 or attempt >= MAX_ATTEMPTS:
return None
return RETRY_SCHEDULE_SECONDS[attempt]
def should_retry(status_code: int | None, error: str | None) -> bool:
"""
Retryable conditions:
- Network error (no status_code)
- 5xx server error
- 408 Request Timeout
- 429 Too Many Requests
Non-retryable:
- 2xx success
- 4xx client error (subscriber bug — they need to fix their endpoint)
"""
if status_code is None:
return True # network error
if 200 <= status_code < 300:
return False # success
if status_code in (408, 429):
return True
if 500 <= status_code < 600:
return True
return False
# ── Subscription filtering ─────────────────────────────────────────
def matching_subscriptions(
*, subscriptions: list[WebhookSubscription], event_type: str, customer_id: str
) -> list[WebhookSubscription]:
"""Filter subscriptions by customer + event-type + enabled flag."""
out = []
for sub in subscriptions:
if not sub.enabled:
continue
if sub.customer_id != customer_id:
continue
if sub.events and event_type not in sub.events:
continue
out.append(sub)
return out
# ── Delivery — pluggable transport for testability ─────────────────
@dataclass
class DeliveryResult:
"""Returned by transport callable — testable without HTTP."""
status_code: int | None = None
error: str | None = None
duration_ms: int = 0
TransportCallable = Callable[[str, bytes, dict[str, str]], DeliveryResult]
def _default_transport(url: str, body: bytes, headers: dict[str, str]) -> DeliveryResult:
"""Real HTTP transport — gated import to keep module testable."""
try:
import httpx
except Exception as exc: # pragma: no cover
return DeliveryResult(error=f"httpx_import_failed: {exc}", duration_ms=0)
started = time.monotonic()
try:
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
resp = client.post(url, content=body, headers=headers)
duration = int((time.monotonic() - started) * 1000)
return DeliveryResult(status_code=resp.status_code, duration_ms=duration)
except Exception as exc:
duration = int((time.monotonic() - started) * 1000)
return DeliveryResult(error=str(exc)[:200], duration_ms=duration)
def deliver_once(
*,
subscription: WebhookSubscription,
event: WebhookEvent,
attempt: int = 1,
transport: TransportCallable | None = None,
extra_headers: dict[str, str] | None = None,
) -> WebhookDelivery:
"""One delivery attempt — pure logic + pluggable transport."""
body = serialize_envelope(event)
signature = sign_payload(secret=subscription.secret, body=body, timestamp=event.timestamp)
headers = {
"Content-Type": "application/json",
"User-Agent": "Dealix-Webhooks/1.0",
"Dealix-Event-Id": event.event_id,
"Dealix-Event-Type": event.event_type,
"Dealix-Signature": signature,
"Dealix-Delivery-Attempt": str(attempt),
}
if extra_headers:
headers.update(extra_headers)
t = transport or _default_transport
res = t(subscription.endpoint_url, body, headers)
return WebhookDelivery(
delivery_id=f"dlv_{uuid.uuid4().hex[:24]}",
event_id=event.event_id,
event_type=event.event_type,
customer_id=subscription.customer_id,
endpoint_url=subscription.endpoint_url,
attempt=attempt,
status_code=res.status_code,
success=bool(res.status_code and 200 <= res.status_code < 300),
error=res.error,
duration_ms=res.duration_ms,
timestamp=int(time.time()),
request_signature=signature,
)
# ── Convenience: dispatch to all matching subscriptions ────────────
@dataclass
class DispatchSummary:
event_id: str
event_type: str
customer_id: str
matched: int
delivered: int
failed: int
deliveries: list[WebhookDelivery] = field(default_factory=list)
def dispatch(
*,
subscriptions: list[WebhookSubscription],
event: WebhookEvent,
transport: TransportCallable | None = None,
) -> DispatchSummary:
"""Dispatch event to matching subscriptions (single attempt each)."""
matched = matching_subscriptions(
subscriptions=subscriptions,
event_type=event.event_type,
customer_id=event.customer_id,
)
deliveries: list[WebhookDelivery] = []
delivered = failed = 0
for sub in matched:
d = deliver_once(subscription=sub, event=event, attempt=1, transport=transport)
deliveries.append(d)
if d.success:
delivered += 1
else:
failed += 1
return DispatchSummary(
event_id=event.event_id,
event_type=event.event_type,
customer_id=event.customer_id,
matched=len(matched),
delivered=delivered,
failed=failed,
deliveries=deliveries,
)
# ── Verification helpers — published in our docs for customers ─────
def verify_signature(
*, secret: str, signature_header: str, body: bytes, max_age_seconds: int = 300
) -> tuple[bool, str | None]:
"""
Verify a Dealix-Signature header on the receiving side.
Returns (is_valid, error_message_or_none).
Customers will copy this snippet into their handler.
"""
if not signature_header or "," not in signature_header:
return False, "malformed_header"
parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
try:
ts = int(parts.get("t", "0"))
except ValueError:
return False, "bad_timestamp"
sig = parts.get("v1", "")
if not sig:
return False, "missing_v1"
# Replay protection
if abs(int(time.time()) - ts) > max_age_seconds:
return False, "stale_signature"
expected = hmac.new(
secret.encode("utf-8"),
f"{ts}.".encode() + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig):
return False, "signature_mismatch"
return True, None