Files
sales-trainer/backend/tests/test_internal_error_logging.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

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