image-lab / src /providers /http.py
Magnus Persson
Add the Hugging Face image adapter, billing-safe retries and try_image script
6494c95
Raw History Blame Contribute Delete
6.36 kB
"""Shared HTTP call for provider adapters: retries, deadline and typed errors (spec section 7).
Retries only on 429, 500, 502, 503, 504 and network timeouts or drops: at most 2, with
exponential backoff and jitter, and never beyond the total time budget. Nothing from a response
body or a request (prompts, keys, image bytes) is logged or put into an exception.
"""
from __future__ import annotations
import asyncio
import logging
import random
import re
import time
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
import httpx
from src.errors import ProviderError, ProviderTimeoutError, RateLimitedError
logger = logging.getLogger(__name__)
T = TypeVar("T")
MAX_RETRIES = 2
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
UNBILLED_RETRY_STATUSES = frozenset({429, 503}) # the provider has not started the work
BACKOFF_BASE_SECONDS = 0.5
BACKOFF_CAP_SECONDS = 4.0
JITTER_SECONDS = 0.25
_CODE_SHAPE = re.compile(r"[a-z0-9_.-]{1,60}")
def _backoff(attempt: int, retry_after: str | None, rand: Callable[[], float]) -> float:
delay = min(BACKOFF_BASE_SECONDS * 2**attempt, BACKOFF_CAP_SECONDS) + rand() * JITTER_SECONDS
if retry_after and retry_after.strip().isdigit():
delay = max(delay, min(float(retry_after), BACKOFF_CAP_SECONDS))
return delay
class Retry(Exception):
"""Raised by one attempt to ask `run_with_retries` for another try."""
def __init__(self, failure: Exception, retry_after: str | None = None) -> None:
super().__init__()
self.failure = failure # what to raise if the retries run out
self.retry_after = retry_after
async def run_with_retries(
attempt: Callable[[float], Awaitable[T]],
*,
total_timeout: float,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rand: Callable[[], float] = random.random,
) -> T:
"""Call `attempt(seconds_left)` until it returns, raises a typed error, or the retries or the
total budget run out. An attempt asks for a retry by raising `Retry`.
At most MAX_RETRIES retries, exponential backoff with jitter, and never beyond the budget,
which is enforced as a hard cap.
"""
try:
async with asyncio.timeout(total_timeout):
deadline = time.monotonic() + total_timeout
failure: Exception = ProviderError()
for tried in range(MAX_RETRIES + 1):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ProviderTimeoutError
try:
return await attempt(remaining)
except Retry as retry:
failure = retry.failure
if tried == MAX_RETRIES:
break
delay = _backoff(tried, retry.retry_after, rand)
if time.monotonic() + delay >= deadline:
break
await sleep(delay)
raise failure
except TimeoutError as exc:
raise ProviderTimeoutError from exc
async def post_json(
client: httpx.AsyncClient,
url: str,
*,
headers: dict[str, str],
payload: dict[str, Any],
total_timeout: float,
what: str,
idempotent: bool = True,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rand: Callable[[], float] = random.random,
) -> dict[str, Any]:
"""POST `payload` as JSON and return the parsed JSON object, or raise a typed error.
`what` names the call in logs ("openai moderation"). `total_timeout` is the whole budget,
retries and waits included. Set `idempotent=False` for a call that costs money: then only 429
and 503 are retried (the provider did not start the work), never timeouts or dropped
connections, so a request that may already be billed is not sent twice.
"""
retry_statuses = RETRY_STATUSES if idempotent else UNBILLED_RETRY_STATUSES
tries = 0
async def attempt(remaining: float) -> dict[str, Any]:
nonlocal tries
tries += 1
try:
response = await client.post(url, headers=headers, json=payload, timeout=remaining)
except httpx.TimeoutException:
logger.warning("%s: timeout (attempt %d)", what, tries)
raise (
Retry(ProviderTimeoutError()) if idempotent else ProviderTimeoutError()
) from None
except httpx.TransportError as exc:
logger.warning("%s: %s (attempt %d)", what, type(exc).__qualname__, tries)
raise (Retry(ProviderError()) if idempotent else ProviderError()) from exc
except httpx.HTTPError as exc:
logger.error("%s: %s, not retried", what, type(exc).__qualname__)
raise ProviderError from exc
status = response.status_code
if status < 400:
return _json_object(response, what)
if status not in retry_statuses:
logger.error("%s: HTTP %d (%s), not retried", what, status, _error_code(response))
raise ProviderError
logger.warning("%s: HTTP %d (%s) (attempt %d)", what, status, _error_code(response), tries)
raise Retry(
RateLimitedError() if status == 429 else ProviderError(),
response.headers.get("retry-after"),
)
return await run_with_retries(attempt, total_timeout=total_timeout, sleep=sleep, rand=rand)
def _error_code(response: httpx.Response) -> str:
"""The provider's short machine code for an error (for example "invalid_api_key"), or "-".
Only a code-shaped token is returned, never the message, which could echo request content.
"""
try:
error = response.json().get("error")
except (ValueError, AttributeError):
return "-"
if isinstance(error, dict):
for key in ("code", "type"):
value = error.get(key)
if isinstance(value, str) and _CODE_SHAPE.fullmatch(value):
return value
return "-"
def _json_object(response: httpx.Response, what: str) -> dict[str, Any]:
try:
data = response.json()
except ValueError as exc:
logger.error("%s: the reply was not JSON", what)
raise ProviderError from exc
if not isinstance(data, dict):
logger.error("%s: the reply was not a JSON object", what)
raise ProviderError
return data