mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
130 lines
3.3 KiB
Python
130 lines
3.3 KiB
Python
|
|
"""Async retry decorators for connector API calls, built on tenacity."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
from collections.abc import Callable
|
||
|
|
from typing import TypeVar
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from tenacity import (
|
||
|
|
before_sleep_log,
|
||
|
|
retry,
|
||
|
|
retry_if_exception,
|
||
|
|
stop_after_attempt,
|
||
|
|
stop_after_delay,
|
||
|
|
wait_exponential_jitter,
|
||
|
|
)
|
||
|
|
|
||
|
|
from app.connectors.exceptions import (
|
||
|
|
ConnectorAPIError,
|
||
|
|
ConnectorAuthError,
|
||
|
|
ConnectorError,
|
||
|
|
ConnectorRateLimitError,
|
||
|
|
ConnectorTimeoutError,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
F = TypeVar("F", bound=Callable)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_retryable(exc: BaseException) -> bool:
|
||
|
|
if isinstance(exc, ConnectorError):
|
||
|
|
return exc.retryable
|
||
|
|
if isinstance(exc, (httpx.TimeoutException, httpx.ConnectError)):
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def build_retry(
|
||
|
|
*,
|
||
|
|
max_attempts: int = 4,
|
||
|
|
max_delay: float = 60.0,
|
||
|
|
initial_delay: float = 1.0,
|
||
|
|
total_timeout: float = 180.0,
|
||
|
|
service: str = "",
|
||
|
|
) -> Callable:
|
||
|
|
"""Configurable tenacity ``@retry`` decorator with exponential backoff + jitter."""
|
||
|
|
_logger = logging.getLogger(f"connector.retry.{service}") if service else logger
|
||
|
|
|
||
|
|
return retry(
|
||
|
|
retry=retry_if_exception(_is_retryable),
|
||
|
|
stop=(stop_after_attempt(max_attempts) | stop_after_delay(total_timeout)),
|
||
|
|
wait=wait_exponential_jitter(initial=initial_delay, max=max_delay),
|
||
|
|
reraise=True,
|
||
|
|
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def retry_on_transient(
|
||
|
|
*,
|
||
|
|
service: str = "",
|
||
|
|
max_attempts: int = 4,
|
||
|
|
) -> Callable:
|
||
|
|
"""Shorthand: retry up to *max_attempts* on rate-limits, timeouts, and 5xx."""
|
||
|
|
return build_retry(max_attempts=max_attempts, service=service)
|
||
|
|
|
||
|
|
|
||
|
|
def raise_for_status(
|
||
|
|
response: httpx.Response,
|
||
|
|
*,
|
||
|
|
service: str = "",
|
||
|
|
) -> None:
|
||
|
|
"""Map non-2xx httpx responses to the appropriate ``ConnectorError``."""
|
||
|
|
if response.is_success:
|
||
|
|
return
|
||
|
|
|
||
|
|
status = response.status_code
|
||
|
|
|
||
|
|
try:
|
||
|
|
body = response.json()
|
||
|
|
except Exception:
|
||
|
|
body = response.text[:500] if response.text else None
|
||
|
|
|
||
|
|
if status == 429:
|
||
|
|
retry_after_raw = response.headers.get("Retry-After")
|
||
|
|
retry_after: float | None = None
|
||
|
|
if retry_after_raw:
|
||
|
|
try:
|
||
|
|
retry_after = float(retry_after_raw)
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
pass
|
||
|
|
raise ConnectorRateLimitError(
|
||
|
|
f"{service} rate limited (429)",
|
||
|
|
service=service,
|
||
|
|
retry_after=retry_after,
|
||
|
|
response_body=body,
|
||
|
|
)
|
||
|
|
|
||
|
|
if status in (401, 403):
|
||
|
|
raise ConnectorAuthError(
|
||
|
|
f"{service} authentication failed ({status})",
|
||
|
|
service=service,
|
||
|
|
status_code=status,
|
||
|
|
response_body=body,
|
||
|
|
)
|
||
|
|
|
||
|
|
if status == 504:
|
||
|
|
raise ConnectorTimeoutError(
|
||
|
|
f"{service} gateway timeout (504)",
|
||
|
|
service=service,
|
||
|
|
status_code=status,
|
||
|
|
response_body=body,
|
||
|
|
)
|
||
|
|
|
||
|
|
if status >= 500:
|
||
|
|
raise ConnectorAPIError(
|
||
|
|
f"{service} server error ({status})",
|
||
|
|
service=service,
|
||
|
|
status_code=status,
|
||
|
|
response_body=body,
|
||
|
|
)
|
||
|
|
|
||
|
|
raise ConnectorAPIError(
|
||
|
|
f"{service} request failed ({status})",
|
||
|
|
service=service,
|
||
|
|
status_code=status,
|
||
|
|
response_body=body,
|
||
|
|
)
|