"""Serialize, sign and send one webhook event.""" from __future__ import annotations from dataclasses import dataclass import hashlib import hmac import json import time from typing import Any import httpx from services.webhooks.security import validate_webhook_url WEBHOOK_TIMEOUT_SECONDS = 10.0 @dataclass(frozen=True) class DeliveryResult: ok: bool status_code: int | None latency_ms: int error: str = "" def canonical_json(payload: dict[str, Any]) -> bytes: return json.dumps( payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True, ).encode("utf-8") def webhook_signature(secret: str, timestamp: str, body: bytes) -> str: signed = timestamp.encode("ascii") + b"." + body digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() return f"v1={digest}" def should_retry(status_code: int | None) -> bool: return status_code is None or status_code in {408, 429} or status_code >= 500 async def deliver_webhook( *, url: str, secret: str, event_id: str, payload: dict[str, Any], ) -> DeliveryResult: """Deliver without following redirects; callers decide whether to retry.""" safe_url = await validate_webhook_url(url) body = canonical_json(payload) timestamp = str(int(time.time())) headers = { "Content-Type": "application/json", "X-AIVideo-Event-Id": event_id, "X-AIVideo-Timestamp": timestamp, "X-AIVideo-Signature": webhook_signature(secret, timestamp, body), } started = time.monotonic() try: async with httpx.AsyncClient( timeout=WEBHOOK_TIMEOUT_SECONDS, follow_redirects=False, ) as client: response = await client.post(safe_url, content=body, headers=headers) latency_ms = round((time.monotonic() - started) * 1000) if 200 <= response.status_code < 300: return DeliveryResult(True, response.status_code, latency_ms) return DeliveryResult( False, response.status_code, latency_ms, f"目标服务返回 HTTP {response.status_code}", ) except httpx.HTTPError as exc: return DeliveryResult( False, None, round((time.monotonic() - started) * 1000), str(exc)[:2048], )