""" Utility functions for the Imago Client SDK. """ import asyncio import logging from typing import Any, Callable, Coroutine, TypeVar from httpx import HTTPStatusError, RequestError from .exceptions import APIError, AuthError, NotFoundError, QuotaError, ImagoError logger = logging.getLogger("imago_client") T = TypeVar("T") async def with_retry( coro_func: Callable[[], Coroutine[Any, Any, T]], max_retries: int = 3, initial_backoff: float = 1.0, max_backoff: float = 10.0, backoff_factor: float = 2.0, ) -> T: """Executes an async function with exponential backoff on retryable HTTP errors.""" retries = 0 backoff = initial_backoff while True: try: return await coro_func() except RequestError as e: if retries >= max_retries: raise ImagoError(f"Request failed after {max_retries} retries: {e}") from e logger.warning(f"Request failed ({e}), retrying in {backoff:.1f}s...") await asyncio.sleep(backoff) retries += 1 backoff = min(backoff * backoff_factor, max_backoff) except HTTPStatusError as e: # Handle specific status codes status = e.response.status_code if status in (408, 429, 500, 502, 503, 504): if status == 429 and retries >= max_retries: raise QuotaError("Rate limit exceeded") from e if retries >= max_retries: raise APIError(f"HTTP {status} failed after {max_retries} retries", status_code=status) from e # specific handling for 429 Retry-After header if status == 429 and "Retry-After" in e.response.headers: try: backoff = float(e.response.headers["Retry-After"]) except ValueError: pass logger.warning(f"HTTP {status}, retrying in {backoff:.1f}s...") await asyncio.sleep(backoff) retries += 1 backoff = min(backoff * backoff_factor, max_backoff) else: _handle_http_error(e) def _handle_http_error(e: HTTPStatusError) -> None: """Raises appropriate SDK exceptions based on HTTP status code.""" status = e.response.status_code try: details = e.response.json() except Exception: details = {"detail": e.response.text} if status in (401, 403): # 403 might indicate missing scope or quota limit depending on details if status == 403 and "quota" in str(details).lower(): raise QuotaError("Quota exceeded") raise AuthError(f"Authentication failed: {details.get('detail')}") elif status == 404: raise NotFoundError("Resource not found") else: raise APIError(f"API returned {status}: {details.get('detail')}", status_code=status, details=details)