"""Тесты `OpenAICompatClient` на мок-транспорте `httpx.MockTransport` (без сети). Покрывает: успешный запрос, retry после 5xx и после сетевой ошибки → успех, исчерпание попыток → `LlmUnavailableError`, открытие circuit breaker после серии неудач (следующий вызов падает без HTTP-запроса) и его закрытие после `breaker_cooldown_s`, а также явное закрытие базового `httpx.Client` (`close()` и поддержка контекстного менеджера). """ import time from collections.abc import Callable import httpx import pytest from core.summarization import llm_client as llm_client_module from core.summarization.llm_client import LlmUnavailableError, OpenAICompatClient @pytest.fixture(autouse=True) def _fast_backoff(monkeypatch: pytest.MonkeyPatch) -> None: """Свести паузы retry к нулю, чтобы тесты не ждали реальный backoff.""" monkeypatch.setattr(llm_client_module, "_INITIAL_BACKOFF_S", 0.0) def _make_client( handler: Callable[[httpx.Request], httpx.Response], **kwargs: object ) -> OpenAICompatClient: transport = httpx.MockTransport(handler) return OpenAICompatClient( base_url="http://llm.test/v1", model="qwen2.5-3b-instruct-q4_k_m", transport=transport, **kwargs, # type: ignore[arg-type] ) def _ok_response(content: str = "готовое резюме") -> httpx.Response: return httpx.Response( 200, json={"choices": [{"message": {"content": content}}]}, ) def test_complete_returns_content_on_success() -> None: calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: calls.append(request) assert request.url == "http://llm.test/v1/chat/completions" body = request.read() assert b'"role": "user"' in body or b'"role":"user"' in body return _ok_response("успех") client = _make_client(handler) result = client.complete("промпт") assert result == "успех" assert len(calls) == 1 def test_complete_uses_per_call_max_tokens_override() -> None: """`max_tokens` явного вызова `complete()` перекрывает дефолт конструктора клиента (ADR-004: разные лимиты map/reduce у `QwenLocal`, один и тот же клиент).""" import json captured: list[int] = [] def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.read()) captured.append(body["max_tokens"]) return _ok_response("ок") client = _make_client(handler, max_tokens=1024) client.complete("промпт map", max_tokens=1024) client.complete("промпт reduce", max_tokens=2048) client.complete("промпт без переопределения") assert captured == [1024, 2048, 1024] def test_complete_retries_after_5xx_then_succeeds() -> None: attempts = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: attempts["count"] += 1 if attempts["count"] < 3: return httpx.Response(503) return _ok_response("успех после 503") client = _make_client(handler, max_attempts=3) result = client.complete("промпт") assert result == "успех после 503" assert attempts["count"] == 3 def test_complete_retries_after_network_error_then_succeeds() -> None: attempts = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: attempts["count"] += 1 if attempts["count"] == 1: raise httpx.ConnectError("соединение разорвано", request=request) return _ok_response("успех после обрыва сети") client = _make_client(handler, max_attempts=3) result = client.complete("промпт") assert result == "успех после обрыва сети" assert attempts["count"] == 2 def test_complete_raises_llm_unavailable_after_exhausting_attempts() -> None: attempts = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: attempts["count"] += 1 return httpx.Response(500) client = _make_client(handler, max_attempts=3, breaker_threshold=100) with pytest.raises(LlmUnavailableError): client.complete("промпт") assert attempts["count"] == 3 def test_breaker_opens_after_threshold_and_blocks_without_http_call() -> None: attempts = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: attempts["count"] += 1 return httpx.Response(500) client = _make_client(handler, max_attempts=1, breaker_threshold=2, breaker_cooldown_s=60.0) with pytest.raises(LlmUnavailableError): client.complete("промпт 1") with pytest.raises(LlmUnavailableError): client.complete("промпт 2") assert attempts["count"] == 2 # обе неудачи дошли до сервера — порог достигнут with pytest.raises(LlmUnavailableError): client.complete("промпт 3") assert attempts["count"] == 2 # третий вызов не сделал HTTP-запрос — breaker открыт def test_breaker_closes_after_cooldown_elapses(monkeypatch: pytest.MonkeyPatch) -> None: attempts = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: attempts["count"] += 1 if attempts["count"] <= 2: return httpx.Response(500) return _ok_response("успех после закрытия breaker") client = _make_client(handler, max_attempts=1, breaker_threshold=2, breaker_cooldown_s=10.0) with pytest.raises(LlmUnavailableError): client.complete("промпт 1") with pytest.raises(LlmUnavailableError): client.complete("промпт 2") assert attempts["count"] == 2 # Имитируем истечение окна отказа сдвигом монотонных часов вперёд (модуль # `time` — общий синглтон интерпретатора, поэтому патчим его напрямую: # изменение видно и внутри `core.summarization.llm_client`). real_monotonic = time.monotonic() monkeypatch.setattr(time, "monotonic", lambda: real_monotonic + 11.0) result = client.complete("промпт 3") assert result == "успех после закрытия breaker" def test_close_closes_underlying_http_client() -> None: """`close()` закрывает базовый `httpx.Client` (освобождает пул соединений).""" def handler(request: httpx.Request) -> httpx.Response: return _ok_response() client = _make_client(handler) client.close() assert client._client.is_closed def test_context_manager_closes_client_on_exit() -> None: """`OpenAICompatClient` работает как контекстный менеджер, закрывающий клиент на выходе.""" def handler(request: httpx.Request) -> httpx.Response: return _ok_response("успех в контексте") with _make_client(handler) as client: assert client.complete("промпт") == "успех в контексте" assert not client._client.is_closed assert client._client.is_closed