Add src/selectel_ml_tui/api/client.py
This commit is contained in:
@@ -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))
|
||||||
Reference in New Issue
Block a user