Add src/selectel_ml_tui/api/client.py

This commit is contained in:
2026-09-02 16:44:01 +03:00
parent fbc7f0eca3
commit 1c57a40140
+93
View File
@@ -0,0 +1,93 @@
"""Базовый HTTP-слой для клиентов API Selectel.
httpx.AsyncClient с таймаутами и лимитом соединений; единая обработка ошибок:
- 429 (rate limit) и 5xx — повтор с экспоненциальной паузой;
- сетевые ошибки httpx — повтор;
- остальные 4xx — исключение SelectelApiError с понятным текстом.
Слой отделён от TUI и тестируется моками httpx.MockTransport.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
class SelectelApiError(RuntimeError):
"""Ошибка обращения к API Selectel.
Атрибут ``status_code`` заполнен для HTTP-ошибок (None для сетевых).
"""
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
class BaseApiClient:
"""Базовый клиент: транспорт, ретраи, понятные ошибки."""
def __init__(
self,
*,
timeout: float = 30.0,
max_retries: int = 2,
transport: httpx.AsyncBaseTransport | None = None,
sleep: Callable[[float], Awaitable[None]] | None = None,
) -> None:
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
self._client = httpx.AsyncClient(
timeout=timeout,
limits=limits,
transport=transport,
)
self._max_retries = max_retries
self._sleep = sleep if sleep is not None else asyncio.sleep
async def aclose(self) -> None:
"""Закрыть транспортные соединения."""
await self._client.aclose()
async def request(
self,
method: str,
url: str,
*,
params: dict[str, str] | None = None,
json: Any = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""Выполнить запрос с повторами и понятной обработкой ошибок."""
last_exc: SelectelApiError | None = None
for attempt in range(self._max_retries + 1):
try:
response = await self._client.request(
method, url, params=params, json=json, headers=headers
)
except httpx.HTTPError as exc:
if attempt < self._max_retries:
await self._sleep(self._backoff_seconds(attempt))
continue
raise SelectelApiError(f"Ошибка сети: {exc}") from exc
if response.status_code < 400:
return response
retriable = response.status_code == 429 or response.status_code >= 500
if retriable and attempt < self._max_retries:
await self._sleep(self._backoff_seconds(attempt))
continue
raise SelectelApiError(
f"HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
raise last_exc if last_exc else SelectelApiError("Запрос не выполнен")
@staticmethod
def _backoff_seconds(attempt: int) -> float:
return float(0.5 * (2**attempt))