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