Vscode / app /providers /http_client.py
Erinaldorodrigues's picture
Release Safe Bet AI v2.2 Precision
888ef7f
Raw
History Blame Contribute Delete
5.71 kB
from __future__ import annotations
import asyncio
from email.utils import parsedate_to_datetime
import logging
import random
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
import httpx
from app.config import MODEL_VERSION
logger = logging.getLogger(__name__)
class ProviderError(RuntimeError):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class ResilientHTTP:
def __init__(self, timeout: float = 20.0, retries: int = 3):
self.timeout = timeout
self.retries = max(1, retries)
self._client: httpx.AsyncClient | None = None
def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
timeout = httpx.Timeout(
timeout=self.timeout,
connect=min(self.timeout, 10.0),
read=self.timeout,
write=min(self.timeout, 10.0),
pool=min(self.timeout, 10.0),
)
self._client = httpx.AsyncClient(
timeout=timeout,
follow_redirects=True,
limits=httpx.Limits(max_connections=8, max_keepalive_connections=4),
headers={"User-Agent": f"SafeBetAI/{MODEL_VERSION}"},
)
return self._client
async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
@staticmethod
def _retry_delay(response: httpx.Response, attempt: int) -> float:
retry_after = response.headers.get("retry-after")
if retry_after:
try:
return min(max(float(retry_after), 0.0), 65.0)
except ValueError:
try:
when = parsedate_to_datetime(retry_after)
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
seconds = (when - datetime.now(timezone.utc)).total_seconds()
return min(max(seconds, 0.0), 65.0)
except Exception:
pass
return min(1.2 * (2 ** attempt) + random.uniform(0.05, 0.55), 12.0)
@staticmethod
def _safe_error_detail(
response: httpx.Response,
params: dict[str, Any] | None,
headers: dict[str, str] | None,
) -> str:
detail = response.text[:500].replace("\n", " ")
for mapping in (params or {}, headers or {}):
for key, value in mapping.items():
key_lower = str(key).lower()
if not any(marker in key_lower for marker in ("key", "token", "auth", "secret")):
continue
secret = str(value)
if len(secret) >= 4:
detail = detail.replace(secret, "[redacted]")
return detail
async def get_json(
self,
url: str,
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
allow_status: set[int] | None = None,
before_attempt: Callable[[], Awaitable[None]] | None = None,
) -> tuple[Any, httpx.Headers]:
allow_status = allow_status or set()
last_exc: Exception | None = None
client = self._get_client()
for attempt in range(self.retries):
try:
if before_attempt is not None:
await before_attempt()
response = await client.get(url, params=params, headers=headers)
if response.status_code in allow_status:
return None, response.headers
if response.status_code in (408, 425, 429, 500, 502, 503, 504):
if attempt < self.retries - 1:
delay = self._retry_delay(response, attempt)
logger.warning(
"HTTP %s em %s; retry %d/%d em %.1fs",
response.status_code,
url,
attempt + 1,
self.retries - 1,
delay,
)
await asyncio.sleep(delay)
continue
if response.status_code >= 400:
detail = self._safe_error_detail(response, params, headers)
raise ProviderError(
f"HTTP {response.status_code} em {url}: {detail}",
response.status_code,
)
try:
return response.json(), response.headers
except ValueError as exc:
raise ProviderError(
f"JSON inválido recebido de {url}: {exc}",
response.status_code,
) from exc
except ProviderError:
raise
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc
if attempt < self.retries - 1:
delay = min(1.0 * (2 ** attempt) + random.uniform(0.05, 0.55), 8.0)
logger.warning(
"Falha de rede em %s; retry %d/%d em %.1fs: %s",
url,
attempt + 1,
self.retries - 1,
delay,
type(exc).__name__,
)
await asyncio.sleep(delay)
continue
break
raise ProviderError(f"Falha de rede em {url}: {last_exc}")