From 1c57a40140c5450358c0d4325e596e8081809952 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:44:01 +0300 Subject: [PATCH] Add src/selectel_ml_tui/api/client.py --- src/selectel_ml_tui/api/client.py | 93 +++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/selectel_ml_tui/api/client.py diff --git a/src/selectel_ml_tui/api/client.py b/src/selectel_ml_tui/api/client.py new file mode 100644 index 0000000..b609ed8 --- /dev/null +++ b/src/selectel_ml_tui/api/client.py @@ -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))