promptlooper/backend/engine/webhooks.py
John Lightner 0f64dfbb02 MAESTRO: Implement webhook CRUD router, async dispatch with retry logic, and delivery logging
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.
2026-04-07 03:41:04 -05:00

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