Full webhook system: CRUD endpoints (list/filter/get/create/update/delete), WebhookDelivery model for delivery audit trail, dispatch engine with 3-attempt retry and exponential backoff, Celery task integration with sync fallback, and webhook firing hooks in runner.py and sweep.py event paths.
184 lines
5.6 KiB
Python
184 lines
5.6 KiB
Python
"""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
|