87 lines
2.3 KiB
Python
87 lines
2.3 KiB
Python
"""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],
|
|
)
|