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
102 lines
3.5 KiB
Python
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,
|
|
)
|