- 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).
61 lines
2.0 KiB
Python
61 lines
2.0 KiB
Python
"""internal_error: server log must carry the exception detail for 500 diagnosis,
|
|
while the client only ever sees the generic message.
|
|
|
|
Uses a handler attached directly to the app logger so the assertion is
|
|
independent of suite-wide logging configuration (other tests may set levels
|
|
or propagation flags on shared loggers).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from app.api.helpers import internal_error
|
|
|
|
|
|
class _Capture(logging.Handler):
|
|
def __init__(self):
|
|
super().__init__(level=logging.ERROR)
|
|
self.messages: list[str] = []
|
|
|
|
def emit(self, record: logging.LogRecord):
|
|
self.messages.append(record.getMessage())
|
|
|
|
|
|
@pytest.fixture()
|
|
def captured_logs(app):
|
|
handler = _Capture()
|
|
app.logger.addHandler(handler)
|
|
# Other tests (alembic migrations in test_db_schema) run logging.config
|
|
# with disable_existing_loggers=True, which disables the app logger.
|
|
# Restore it so this assertion is independent of test order.
|
|
app.logger.disabled = False
|
|
yield handler
|
|
app.logger.removeHandler(handler)
|
|
|
|
|
|
def test_internal_error_logs_detail_and_hides_it_from_client(app, captured_logs):
|
|
sentinel = "404 model 'qwen3.8' not found on this endpoint"
|
|
with app.app_context():
|
|
err = internal_error("LLM service unavailable", ValueError(sentinel))
|
|
|
|
# Client-facing payload stays generic — the sentinel must not leak.
|
|
assert err.message == "LLM service unavailable"
|
|
assert err.status == 500
|
|
assert sentinel not in err.message
|
|
|
|
# Server-side log DOES carry the detail, so a 500 is diagnosable.
|
|
logged = " ".join(captured_logs.messages)
|
|
assert "LLM service unavailable" in logged
|
|
assert "ValueError" in logged
|
|
assert sentinel in logged
|
|
|
|
|
|
def test_internal_error_default_status_and_type(app, captured_logs):
|
|
with app.app_context():
|
|
err = internal_error("boom", RuntimeError("disk full"))
|
|
assert err.status == 500
|
|
logged = " ".join(captured_logs.messages)
|
|
assert "RuntimeError" in logged and "disk full" in logged
|