Implement webhook
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""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],
|
||||
)
|
||||
Reference in New Issue
Block a user