"""Institutions Phase 2 — outbound org webhooks (attempt.completed, roster.updated, ...). No scheduler/queue infra exists in this repo — retry delivery runs as a lightweight in-process asyncio task started alongside lifespan. Fine for the current single-instance SQLite deployment; would need revisiting for multiple replicas. """ import asyncio import hashlib import hmac import json import logging import uuid import httpx logger = logging.getLogger(__name__) MAX_DELIVERY_ATTEMPTS = 5 RETRY_SWEEP_INTERVAL_SECONDS = 60 async def create_webhook(pool, org_id: str, url: str, secret: str, event_types: str) -> dict: webhook_id = f"whk_{uuid.uuid4().hex[:12]}" await pool.execute( "INSERT INTO org_webhooks (id, org_id, url, secret, event_types) VALUES (?,?,?,?,?)", webhook_id, org_id, url, secret, event_types, ) return dict(await pool.fetchrow("SELECT * FROM org_webhooks WHERE id=?", webhook_id)) async def list_webhooks(pool, org_id: str) -> list[dict]: rows = await pool.fetch("SELECT * FROM org_webhooks WHERE org_id=? ORDER BY created_at DESC", org_id) return [dict(r) for r in rows] def _sign(secret: str, payload: str) -> str: return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() async def enqueue_delivery(pool, org_id: str, event_type: str, data: dict) -> None: hooks = await pool.fetch( "SELECT * FROM org_webhooks WHERE org_id=? AND active=1", org_id, ) payload = json.dumps(data, ensure_ascii=False) for hook in hooks: if event_type not in [e.strip() for e in hook["event_types"].split(",")]: continue delivery_id = f"whd_{uuid.uuid4().hex[:12]}" await pool.execute( "INSERT INTO org_webhook_deliveries (id, webhook_id, event_type, payload) VALUES (?,?,?,?)", delivery_id, hook["id"], event_type, payload, ) async def _deliver_one(pool, delivery: dict, webhook: dict) -> None: signature = _sign(webhook["secret"], delivery["payload"]) headers = {"Content-Type": "application/json", "X-Webhook-Signature": signature} try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post(webhook["url"], content=delivery["payload"], headers=headers) if resp.status_code < 300: await pool.execute( "UPDATE org_webhook_deliveries SET status='delivered', delivered_at=datetime('now') WHERE id=?", delivery["id"], ) return raise RuntimeError(f"delivery returned {resp.status_code}") except Exception as exc: attempt = delivery["attempt"] + 1 status = "failed" if attempt >= MAX_DELIVERY_ATTEMPTS else "pending" await pool.execute( "UPDATE org_webhook_deliveries SET attempt=?, status=?, last_error=? WHERE id=?", attempt, status, str(exc)[:300], delivery["id"], ) async def retry_sweep_once(pool) -> None: pending = await pool.fetch( "SELECT * FROM org_webhook_deliveries WHERE status='pending' ORDER BY created_at LIMIT 50", ) for delivery in pending: webhook = await pool.fetchrow("SELECT * FROM org_webhooks WHERE id=?", delivery["webhook_id"]) if webhook: await _deliver_one(pool, dict(delivery), dict(webhook)) async def retry_sweep_loop(pool) -> None: while True: try: await asyncio.sleep(RETRY_SWEEP_INTERVAL_SECONDS) await retry_sweep_once(pool) except asyncio.CancelledError: raise except Exception as exc: logger.warning("webhook retry sweep failed: %s", exc)