Files
sales-trainer/backend/tests/test_llm_retry_queue.py
Macky d17462edc4 fix: retry transient LLM failures to survive shared-server queue, log 500 detail
- llm.py: _create() wrapper retries APIConnectionError (incl. timeout) up to
  LLM_MAX_ATTEMPTS=3 with LLM_RETRY_DELAY_SECONDS=5s backoff; 4xx still fails
  fast. The local LLM server is shared with Hermes and serializes requests, so
  a request can be dropped by the client timeout while queued — chat/send was
  500ing on that.
- helpers.py: internal_error() now logs full exception detail server-side
  (was only the type name), so 500s are diagnosable; client message unchanged.
- config.py: LLM_MAX_ATTEMPTS / LLM_RETRY_DELAY_SECONDS env knobs.
- tests: test_llm_retry_queue (transient retried, 4xx not retried, no retry on
  success), test_internal_error_logging (detail in log, hidden from client,
  order-independent via explicit handler + logger re-enable).
2026-10-02 17:04:55 +07:00

96 lines
2.5 KiB
Python

"""LLM client: transient failures (timeout / connection reset — what a shared,
queueing LLM server produces under load) are retried so chat survives the queue;
a 4xx from the server (bad key / model) is NOT retried and fails fast.
"""
from __future__ import annotations
import httpx
import pytest
from app.llm import LLMClient, LLMError
from openai import APIStatusError, APITimeoutError
def _make_client(max_attempts: int = 3) -> LLMClient:
c = LLMClient(base_url="http://localhost:9/v1", api_key="test-key", model="m")
c.max_attempts = max_attempts
c.retry_delay = 0.0
return c
def _resp():
message = type("M", (), {"content": "ok"})()
choice = type("C", (), {"message": message})()
return type("R", (), {"choices": [choice]})()
def _patch_sleep(monkeypatch):
import app.llm as llm
monkeypatch.setattr(llm.time, "sleep", lambda s: None)
def test_transient_failure_retries_then_succeeds(monkeypatch):
c = _make_client(max_attempts=3)
_patch_sleep(monkeypatch)
calls = {"n": 0}
def flaky(**kwargs):
calls["n"] += 1
if calls["n"] < 3:
raise APITimeoutError(request="req")
return _resp()
c.client.chat.completions.create = flaky
assert c.complete("sys", "user") == "ok"
assert calls["n"] == 3
def test_persistent_transient_failure_raises_after_max_attempts(monkeypatch):
c = _make_client(max_attempts=3)
_patch_sleep(monkeypatch)
calls = {"n": 0}
def always_timeout(**kwargs):
calls["n"] += 1
raise APITimeoutError(request="req")
c.client.chat.completions.create = always_timeout
with pytest.raises(LLMError):
c.complete("sys", "user")
assert calls["n"] == 3
def test_4xx_not_retried_fails_fast(monkeypatch):
c = _make_client(max_attempts=3)
_patch_sleep(monkeypatch)
calls = {"n": 0}
def bad_model(**kwargs):
calls["n"] += 1
raise APIStatusError(
message="model not found",
response=httpx.Response(404, request="req"),
body=None,
)
c.client.chat.completions.create = bad_model
with pytest.raises(LLMError):
c.complete("sys", "user")
# A 4xx is not transient: exactly one attempt, no retry.
assert calls["n"] == 1
def test_success_no_retry(monkeypatch):
c = _make_client(max_attempts=3)
_patch_sleep(monkeypatch)
calls = {"n": 0}
def ok(**kwargs):
calls["n"] += 1
return _resp()
c.client.chat.completions.create = ok
assert c.complete("sys", "user") == "ok"
assert calls["n"] == 1