"""Webhook dispatch — fire HTTP callbacks when events occur. Active webhook configs matching the event_type are fetched from the DB, and each gets an async HTTP POST with the event payload. Retries up to 3 attempts with exponential backoff. Delivery status is logged to the WebhookDelivery table. """ import json import logging import time import uuid from datetime import datetime, timezone from typing import Any import httpx from sqlalchemy.orm import Session from models import WebhookConfig, WebhookDelivery logger = logging.getLogger(__name__) MAX_RETRIES = 3 BACKOFF_BASE = 2 # seconds TIMEOUT = 10 # seconds per request def get_active_webhooks(db: Session, event_type: str) -> list[WebhookConfig]: """Return all active webhooks matching the given event_type.""" return ( db.query(WebhookConfig) .filter(WebhookConfig.event_type == event_type, WebhookConfig.is_active.is_(True)) .all() ) def _log_delivery( db: Session, webhook_id: uuid.UUID, event_type: str, payload: dict[str, Any], status_code: int | None, success: bool, attempts: int, error_message: str | None = None, ) -> WebhookDelivery: """Create a WebhookDelivery record.""" delivery = WebhookDelivery( webhook_id=webhook_id, event_type=event_type, payload=payload, status_code=status_code, success=success, attempts=attempts, error_message=error_message, ) db.add(delivery) db.commit() db.refresh(delivery) return delivery def deliver_webhook( db: Session, webhook: WebhookConfig, event_type: str, payload: dict[str, Any], ) -> bool: """Deliver a webhook synchronously with retry logic. Returns True if delivery succeeded, False otherwise. """ headers = {"Content-Type": "application/json"} if webhook.headers: headers.update(webhook.headers) last_error: str | None = None last_status_code: int | None = None for attempt in range(1, MAX_RETRIES + 1): try: with httpx.Client(timeout=TIMEOUT) as client: response = client.post( webhook.url, content=json.dumps(payload, default=str), headers=headers, ) last_status_code = response.status_code if 200 <= response.status_code < 300: _log_delivery(db, webhook.id, event_type, payload, response.status_code, True, attempt) return True last_error = f"HTTP {response.status_code}: {response.text[:500]}" except Exception as exc: last_error = f"{type(exc).__name__}: {str(exc)[:500]}" last_status_code = None if attempt < MAX_RETRIES: time.sleep(BACKOFF_BASE ** attempt) # All retries exhausted logger.warning( "Webhook delivery failed after %d attempts: webhook_id=%s url=%s error=%s", MAX_RETRIES, webhook.id, webhook.url, last_error, ) _log_delivery(db, webhook.id, event_type, payload, last_status_code, False, MAX_RETRIES, last_error) return False async def deliver_webhook_async( db: Session, webhook: WebhookConfig, event_type: str, payload: dict[str, Any], ) -> bool: """Deliver a webhook asynchronously with retry logic. Returns True if delivery succeeded, False otherwise. """ headers = {"Content-Type": "application/json"} if webhook.headers: headers.update(webhook.headers) last_error: str | None = None last_status_code: int | None = None for attempt in range(1, MAX_RETRIES + 1): try: async with httpx.AsyncClient(timeout=TIMEOUT) as client: response = await client.post( webhook.url, content=json.dumps(payload, default=str), headers=headers, ) last_status_code = response.status_code if 200 <= response.status_code < 300: _log_delivery(db, webhook.id, event_type, payload, response.status_code, True, attempt) return True last_error = f"HTTP {response.status_code}: {response.text[:500]}" except Exception as exc: last_error = f"{type(exc).__name__}: {str(exc)[:500]}" last_status_code = None if attempt < MAX_RETRIES: import asyncio await asyncio.sleep(BACKOFF_BASE ** attempt) logger.warning( "Webhook delivery failed after %d attempts: webhook_id=%s url=%s error=%s", MAX_RETRIES, webhook.id, webhook.url, last_error, ) _log_delivery(db, webhook.id, event_type, payload, last_status_code, False, MAX_RETRIES, last_error) return False def dispatch_webhooks(db: Session, event_type: str, payload: dict[str, Any]) -> int: """Find active webhooks for event_type and deliver to each. Returns the number of successful deliveries. """ webhooks = get_active_webhooks(db, event_type) if not webhooks: return 0 successes = 0 for webhook in webhooks: if deliver_webhook(db, webhook, event_type, payload): successes += 1 return successes async def dispatch_webhooks_async(db: Session, event_type: str, payload: dict[str, Any]) -> int: """Async variant — find active webhooks and deliver to each. Returns the number of successful deliveries. """ webhooks = get_active_webhooks(db, event_type) if not webhooks: return 0 successes = 0 for webhook in webhooks: if await deliver_webhook_async(db, webhook, event_type, payload): successes += 1 return successes