from __future__ import annotations import asyncio import random from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any import httpx from yuxi.utils.logging_config import logger from .network_errors import NetworkErrorClass, classify_http_error, classify_network_exception _RETRYABLE_EXCEPTIONS = ( httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError, httpx.RemoteProtocolError, ) DEFAULT_RETRY_ATTEMPTS = 3 DEFAULT_RETRY_MIN_DELAY = 1.0 DEFAULT_RETRY_MAX_DELAY = 30.0 @dataclass class RetryConfig: max_retries: int = DEFAULT_RETRY_ATTEMPTS base_delay: float = DEFAULT_RETRY_MIN_DELAY max_delay: float = DEFAULT_RETRY_MAX_DELAY jitter_enabled: bool = True jitter_factor: float = 0.3 @classmethod def from_config(cls, config: dict[str, Any]) -> RetryConfig: return cls( max_retries=config.get("retry_attempts", DEFAULT_RETRY_ATTEMPTS), base_delay=config.get("retry_min_delay", DEFAULT_RETRY_MIN_DELAY), max_delay=config.get("retry_max_delay", DEFAULT_RETRY_MAX_DELAY), ) def _calc_delay(attempt: int, config: RetryConfig, error_class: NetworkErrorClass | None = None) -> float: delay = config.base_delay * (2**attempt) if error_class == NetworkErrorClass.RATE_LIMIT: delay = max(delay, 5.0) if config.jitter_enabled: delay = delay * (1.0 + random.uniform(-config.jitter_factor, config.jitter_factor)) return min(delay, config.max_delay) class SendRetrier: def __init__(self, config: RetryConfig | None = None): self._config = config or RetryConfig() @property def config(self) -> RetryConfig: return self._config def update_config(self, config: dict[str, Any]) -> None: self._config = RetryConfig.from_config({**self._config.__dict__, **config}) async def execute( self, fn: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any, ) -> Any: last_exc: Exception | None = None for attempt in range(self._config.max_retries): try: return await fn(*args, **kwargs) except _RETRYABLE_EXCEPTIONS as e: last_exc = e if attempt < self._config.max_retries - 1: error_class = classify_network_exception(e) delay = _calc_delay(attempt, self._config, error_class) logger.debug( f"[WeChat/Retry] Attempt {attempt + 1}/{self._config.max_retries} " f"failed ({error_class.value}): {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 in (NetworkErrorClass.FATAL, NetworkErrorClass.AUTH): logger.warning( f"[WeChat/Retry] Non-retryable HTTP error {e.response.status_code} ({error_class.value}): {e}" ) raise last_exc = e if attempt < self._config.max_retries - 1: delay = _calc_delay(attempt, self._config, error_class) 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]