207 lines
7.4 KiB
Python
207 lines
7.4 KiB
Python
"""Тесты `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
|