Skip to main content

af_llm/
error.rs

1//! Error type for the LLM client.
2
3use thiserror::Error;
4
5/// Everything that can go wrong on the way to a completion.
6///
7/// Mirrors the failure modes of the Python `llm_completion` wrapper, but where
8/// that returned `None` and swallowed the cause, we keep a typed error so the
9/// caller decides how to degrade.
10#[derive(Debug, Error)]
11pub enum LlmError {
12    /// Invalid, unsupported, or unauthorized input rejected before model dispatch.
13    /// Adapters must not use this variant after sending a provider request.
14    #[error("model input rejected: {0}")]
15    InvalidInput(String),
16    /// Caller cancelled the provider attempt.
17    #[error("llm request cancelled")]
18    Cancelled,
19
20    /// Caller deadline elapsed before the provider attempt settled.
21    #[error("llm request deadline exceeded")]
22    DeadlineExceeded,
23    /// Circuit breaker is open — we fail fast without hitting the provider.
24    #[error("llm circuit breaker is open; failing fast")]
25    CircuitOpen,
26
27    /// Transport-level failure (connection, TLS, timeout).
28    #[error("llm transport error: {0}")]
29    Transport(#[from] reqwest::Error),
30
31    /// Provider returned a non-2xx status.
32    #[error("llm api error: status {status}, body: {body}")]
33    Api {
34        /// HTTP status returned.
35        status: u16,
36        /// Response body, truncated for logs.
37        body: String,
38    },
39
40    /// Response body could not be decoded into the expected shape.
41    #[error("llm response decode error: {0}")]
42    Decode(#[from] serde_json::Error),
43
44    /// A streaming response violated the SSE completion contract.
45    #[error("llm stream protocol error: {0}")]
46    StreamProtocol(String),
47
48    /// A streaming response contained bytes that were not valid UTF-8.
49    #[error("llm stream utf-8 error: {0}")]
50    InvalidUtf8(#[from] std::string::FromUtf8Error),
51}
52
53impl LlmError {
54    /// Provider explicitly rejected the input context; generic payload errors do not qualify.
55    pub fn is_context_overflow(&self) -> bool {
56        let Self::Api {
57            status: 400 | 413,
58            body,
59        } = self
60        else {
61            return false;
62        };
63        let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
64            return false;
65        };
66        value
67            .pointer("/error/code")
68            .and_then(serde_json::Value::as_str)
69            == Some("context_length_exceeded")
70    }
71
72    /// Whether retrying this error class could plausibly succeed.
73    ///
74    /// A 4xx (except 408/429) is the caller's fault and will fail identically on
75    /// retry; transport errors and 5xx/408/429 are worth another attempt.
76    pub fn is_retryable(&self) -> bool {
77        match self {
78            LlmError::Transport(_) | LlmError::StreamProtocol(_) | LlmError::InvalidUtf8(_) => true,
79            LlmError::Api { status, .. } => {
80                *status == 408 || *status == 429 || (500..600).contains(status)
81            }
82            // Circuit-open and decode failures are not improved by retry.
83            LlmError::InvalidInput(_)
84            | LlmError::CircuitOpen
85            | LlmError::Decode(_)
86            | LlmError::Cancelled
87            | LlmError::DeadlineExceeded => false,
88        }
89    }
90}
91
92/// Result alias for this crate's error.
93pub type Result<T> = std::result::Result<T, LlmError>;