Introduce an admin portal (React + Nginx), WebSocket routing, and API versioning middleware with `/api/v1/` prefix deprecation. Add master API key authentication, new Prometheus metrics for AI token consumption and active WebSockets, and extend S3 config with a public endpoint URL. Update test paths and fixtures to align with the new routing structure.
78 lines
2.9 KiB
Python
78 lines
2.9 KiB
Python
"""
|
|
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)
|