151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
import asyncio
|
|
import logging
|
|
|
|
import httpx
|
|
from jinja2 import BaseLoader, StrictUndefined
|
|
from jinja2.sandbox import SandboxedEnvironment
|
|
|
|
from yuxi.channel.extensions.generic_webhook.types import OutboundConfig, OutboundResult
|
|
|
|
logger = logging.getLogger("yuxi.channel.generic_webhook.outbound")
|
|
|
|
MAX_RETRIES = 3
|
|
RETRY_BACKOFF_BASE = 1.0
|
|
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
CONNECT_TIMEOUT = 10.0
|
|
REQUEST_TIMEOUT = 30.0
|
|
|
|
|
|
class TemplateRenderer:
|
|
def __init__(self):
|
|
self._env = SandboxedEnvironment(
|
|
loader=BaseLoader(),
|
|
undefined=StrictUndefined,
|
|
autoescape=False,
|
|
extensions=[],
|
|
)
|
|
|
|
def render(self, template_str: str, context: dict) -> str:
|
|
if not template_str:
|
|
return ""
|
|
try:
|
|
template = self._env.from_string(template_str)
|
|
return template.render(**context)
|
|
except Exception as e:
|
|
logger.error("模板渲染失败: %s", e)
|
|
raise ValueError(f"模板渲染失败: {e}")
|
|
|
|
|
|
class WebhookOutbound:
|
|
def __init__(self):
|
|
self._http: httpx.AsyncClient | None = None
|
|
self._renderer = TemplateRenderer()
|
|
|
|
async def _ensure_http(self):
|
|
if self._http is None:
|
|
self._http = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(CONNECT_TIMEOUT, read=REQUEST_TIMEOUT),
|
|
)
|
|
|
|
async def send(self, config: OutboundConfig, context: dict) -> OutboundResult:
|
|
if not config or not config.url:
|
|
return OutboundResult(success=False, error="未配置出站 URL")
|
|
|
|
retry_max = min(config.retry_max or MAX_RETRIES, 5)
|
|
backoff = config.retry_backoff_base or RETRY_BACKOFF_BASE
|
|
|
|
await self._ensure_http()
|
|
|
|
body = self._render_payload(config, context)
|
|
headers = self._build_headers(config, body)
|
|
|
|
for attempt in range(retry_max):
|
|
try:
|
|
resp = await self._http.request(
|
|
method=config.method or "POST",
|
|
url=config.url,
|
|
content=body,
|
|
headers=headers,
|
|
)
|
|
|
|
if 200 <= resp.status_code < 300:
|
|
return OutboundResult(success=True, status_code=resp.status_code, attempt=attempt + 1)
|
|
|
|
retry_statuses = set(config.retry_statuses) if config.retry_statuses else RETRYABLE_STATUSES
|
|
if resp.status_code not in retry_statuses:
|
|
logger.warning(
|
|
"出站请求失败(不可重试): endpoint %s, status=%d, body=%s",
|
|
context.get("endpoint_id"),
|
|
resp.status_code,
|
|
resp.text[:200],
|
|
)
|
|
return OutboundResult(
|
|
success=False,
|
|
status_code=resp.status_code,
|
|
error=f"HTTP {resp.status_code}: {resp.text[:200]}",
|
|
attempt=attempt + 1,
|
|
)
|
|
|
|
logger.warning(
|
|
"出站请求失败(可重试): endpoint %s, status=%d, attempt=%d/%d",
|
|
context.get("endpoint_id"),
|
|
resp.status_code,
|
|
attempt + 1,
|
|
retry_max,
|
|
)
|
|
|
|
except (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError) as e:
|
|
logger.warning(
|
|
"出站请求网络异常: endpoint %s, attempt=%d/%d, error=%s",
|
|
context.get("endpoint_id"),
|
|
attempt + 1,
|
|
retry_max,
|
|
e,
|
|
)
|
|
except Exception as e:
|
|
logger.error("出站请求未知异常: %s", e)
|
|
return OutboundResult(success=False, error=str(e), attempt=attempt + 1)
|
|
|
|
if attempt < retry_max - 1:
|
|
delay = backoff * (2**attempt)
|
|
await asyncio.sleep(delay)
|
|
|
|
return OutboundResult(
|
|
success=False,
|
|
error=f"重试 {retry_max} 次后仍失败",
|
|
attempt=retry_max,
|
|
)
|
|
|
|
def _build_headers(self, config: OutboundConfig, body: str) -> dict:
|
|
headers = {"Content-Type": config.content_type or "application/json"}
|
|
|
|
if config.auth_type == "bearer" and config.auth_token:
|
|
headers["Authorization"] = f"Bearer {config.auth_token}"
|
|
elif config.auth_type == "header" and config.auth_token and config.auth_header_name:
|
|
headers[config.auth_header_name] = config.auth_token
|
|
elif config.auth_type == "hmac_sha256" and config.auth_token:
|
|
import hashlib
|
|
import hmac
|
|
|
|
signature = hmac.new(
|
|
config.auth_token.encode(),
|
|
body.encode(),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
headers[config.auth_header_name or "X-Signature-256"] = signature
|
|
|
|
return headers
|
|
|
|
def _render_payload(self, config: OutboundConfig, context: dict) -> str:
|
|
if not config.payload_template:
|
|
import json
|
|
|
|
return json.dumps(context, ensure_ascii=False, default=str)
|
|
|
|
return self._renderer.render(config.payload_template, context)
|
|
|
|
async def close(self):
|
|
if self._http:
|
|
await self._http.aclose()
|
|
self._http = None
|