Files
pablan/backend/app/llm/errors.py
T
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 09:21:37 +02:00

102 lines
3.5 KiB
Python

"""What it means when an endpoint does not answer.
Separate from `client.py` because eight modules catch this and none of them
talk to an endpoint: routers, modes and the authoring code only need to know
what went wrong and how to say it. The client itself stays the one place that
CALLS an endpoint.
Nothing here ever carries content — not the prompt, not the reply, not the
original exception's message. A failure is described by class name, status
code and duration, which is everything a log line may hold (rule 12).
"""
import time
# Exception class names that mean "nothing answered at the other end" versus
# "the other end is there but not ready for us". The SDK wraps both, so the
# class name is all we have: APITimeoutError subclasses APIConnectionError,
# which is why timeouts are matched first.
_TIMEOUT_CAUSES = frozenset(
{"APITimeoutError", "ReadTimeout", "PoolTimeout", "TimeoutError"}
)
_CONNECTION_CAUSES = frozenset(
{"APIConnectionError", "ConnectError", "ConnectTimeout", "RemoteProtocolError"}
)
# Server said "come back later" (rate limit, no free slot, model still loading).
_BUSY_STATUS = frozenset({408, 429, 503, 504})
# Server said "not with these credentials / not this model".
_SETUP_STATUS = frozenset({401, 403, 404})
class LLMError(Exception):
"""Sanitized LLM failure: structured metadata for debugging —
never content, never original exception messages.
Fields: role, kind, status ("error" | "invalid"), cause_type (original
exception CLASS NAME only), status_code (HTTP, if any), duration_ms,
attempt (chat_json: 1 or 2).
"""
def __init__(
self,
message: str,
*,
role: str,
kind: str,
status: str,
cause_type: str | None = None,
status_code: int | None = None,
duration_ms: int | None = None,
attempt: int | None = None,
) -> None:
super().__init__(message)
self.role = role
self.kind = kind
self.status = status
self.cause_type = cause_type
self.status_code = status_code
self.duration_ms = duration_ms
self.attempt = attempt
@property
def code(self) -> str:
"""The API error code for this failure — the ONE place an endpoint
failure is classified, so every caller reports the same reason and the
frontend can phrase it (`docs/api-protocol.md`).
`llm_busy` and `llm_unreachable` are worth telling apart: the first is
worth retrying in a moment, the second needs someone to start the
endpoint.
"""
if self.status_code in _BUSY_STATUS:
return "llm_busy"
if self.status_code in _SETUP_STATUS:
return "llm_misconfigured"
if self.cause_type in _TIMEOUT_CAUSES:
return "llm_busy"
if self.cause_type in _CONNECTION_CAUSES:
return "llm_unreachable"
return "llm_failed"
def llm_error(
kind: str,
role: str,
exc: Exception,
started: float,
*,
attempt: int | None = None,
) -> LLMError:
"""Wrap whatever the SDK raised, keeping only what may be logged."""
status_code = getattr(exc, "status_code", None)
return LLMError(
f"{kind} failed (role={role}): {type(exc).__name__}",
role=role,
kind=kind,
status="error",
cause_type=type(exc).__name__,
status_code=status_code if isinstance(status_code, int) else None,
duration_ms=round((time.monotonic() - started) * 1000),
attempt=attempt,
)