Add admin panel, WebSocket support, and API versioning
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.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user