from __future__ import annotations import asyncio import random from collections.abc import Awaitable, Callable from enum import StrEnum from typing import Any import httpx from yuxi.utils.logging_config import logger class NetworkErrorClass(StrEnum): RECOVERABLE = "recoverable" RATE_LIMIT = "rate_limit" SERVER_ERROR = "server_error" AUTH = "auth" FATAL = "fatal" _RETRYABLE_EXCEPTIONS = ( httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError, httpx.RemoteProtocolError, ) def classify_http_error(status_code: int) -> NetworkErrorClass: if status_code == 429: return NetworkErrorClass.RATE_LIMIT if status_code in (500, 502, 503, 504): return NetworkErrorClass.SERVER_ERROR if status_code in (401, 403): return NetworkErrorClass.AUTH if 400 <= status_code < 500: return NetworkErrorClass.FATAL return NetworkErrorClass.RECOVERABLE def _jitter(base: float, factor: float = 0.3) -> float: return base * (1.0 + random.uniform(-factor, factor)) async def retry_with_backoff( fn: Callable[..., Awaitable[Any]], *args: Any, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 30.0, jitter_enabled: bool = True, **kwargs: Any, ) -> Any: last_exc: Exception | None = None for attempt in range(max_retries): try: return await fn(*args, **kwargs) except _RETRYABLE_EXCEPTIONS as e: last_exc = e if attempt < max_retries - 1: delay = base_delay * (2**attempt) if jitter_enabled: delay = _jitter(delay) delay = min(delay, max_delay) logger.debug(f"[WeChat/Retry] Attempt {attempt + 1} failed: {e}, retrying in {delay:.1f}s") await asyncio.sleep(delay) except httpx.HTTPStatusError as e: error_class = classify_http_error(e.response.status_code) if error_class == NetworkErrorClass.FATAL or error_class == NetworkErrorClass.AUTH: logger.warning(f"[WeChat/Retry] Non-retryable HTTP error {e.response.status_code}: {e}") raise last_exc = e if attempt < max_retries - 1: delay = base_delay * (2**attempt) if error_class == NetworkErrorClass.RATE_LIMIT: delay = max(delay, 5.0) if jitter_enabled: delay = _jitter(delay) delay = min(delay, max_delay) logger.debug( f"[WeChat/Retry] HTTP {e.response.status_code} ({error_class.value}): retrying in {delay:.1f}s" ) await asyncio.sleep(delay) except Exception: raise raise last_exc # type: ignore[misc]