"""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, )