Skip to main content

quorum_rs/llms/
error.rs

1//! `LlmError` — the typed error returned by [`AiModel::chat_completion`].
2//!
3//! Lives in the `llms` module because it's the AiModel trait's error
4//! type, not a telemetry concept. The complementary [`LlmErrorClass`]
5//! enum (the snake_case variant tag that lands in the
6//! `LlmRequestFailed.error_class` payload) stays in
7//! [`crate::telemetry`] since it's part of the operator-facing event
8//! schema. [`LlmError::classify`] bridges the two.
9//!
10//! Each provider impl maps its native error (reqwest, serde_json,
11//! HTTP status codes, async-openai's `OpenAIError::ApiError.code`)
12//! into this enum at the trait boundary so callers can pattern-match
13//! variants instead of scraping formatted error strings.
14
15use crate::telemetry::LlmErrorClass;
16
17/// Typed error produced at the [`AiModel`](crate::llms::AiModel) boundary.
18#[derive(Debug, thiserror::Error)]
19pub enum LlmError {
20    #[error("rate limited")]
21    RateLimit {
22        retry_after_ms: Option<u64>,
23        status: u16,
24    },
25    #[error("payment required")]
26    PaymentRequired { status: u16 },
27    #[error("server error (status {status})")]
28    ServerError { status: u16 },
29    /// vLLM/OpenAI-compatible context-window-exceeded error after
30    /// reactive shrink retries are exhausted. Carries the server-
31    /// reported `limit` (model max context tokens) and `tokens` (the
32    /// request's input-token count) so dashboards can chart how often
33    /// agents are crossing which model's window without re-parsing
34    /// the error message.
35    #[error("context overflow ({tokens} input tokens exceeded {limit}-token model limit)")]
36    ContextOverflow { tokens: u32, limit: u32 },
37    /// HTTP 400 that isn't a recognized context-overflow. The provider's
38    /// response `body` carries the real reason (bad tool schema, token
39    /// math, unsupported param). `Display` stays terse — the body can
40    /// echo a fragment of the prompt — so logs/dumps don't leak it; the
41    /// body is exposed only via [`LlmError::detail`], which operator-local
42    /// surfaces (e.g. `quorum smoke-test`) opt into showing.
43    #[error("bad request (status {status})")]
44    BadRequest { status: u16, body: String },
45    /// A non-2xx HTTP status that isn't one of the specific categories above
46    /// (401 auth, 403, 404 model-unavailable, 422, …). Preserves `status`
47    /// structurally so telemetry charts it and operators can tell a dead model
48    /// id (404) from an auth failure (401) — the previous `Other` catch-all
49    /// flattened all of these to an opaque "other" with no status. `body` is
50    /// withheld from `Display` like `BadRequest`; recover it via [`LlmError::detail`].
51    #[error("api error (status {status})")]
52    Api { status: u16, body: String },
53    #[error("transport")]
54    Transport(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
55    #[error("parse")]
56    Parse(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
57    #[error("other")]
58    Other(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
59}
60
61impl LlmError {
62    /// Render the full error chain (this error + every wrapped
63    /// `source`) as a multi-line string, root cause last:
64    ///
65    /// ```text
66    /// transport
67    ///   caused by: hyper error
68    ///   caused by: connection reset by peer (os error 104)
69    /// ```
70    ///
71    /// `LlmError` only wraps a source on `Transport`, `Parse`, and
72    /// `Other`; the structured variants (`RateLimit`,
73    /// `PaymentRequired`, `ServerError`, `ContextOverflow`) render
74    /// as a single line because they carry their detail inline in
75    /// the `thiserror` format string.
76    ///
77    /// Operators see the chain in logs (`tracing::error!(error.chain
78    /// = %err.display_chain(), ...)`) and dashboards (the JSON
79    /// shape exposes one string field) without losing the root
80    /// cause to a flattened one-liner.
81    pub fn display_chain(&self) -> String {
82        use std::error::Error as _;
83        let mut out = self.to_string();
84        let mut cursor: Option<&dyn std::error::Error> = self.source();
85        while let Some(layer) = cursor {
86            out.push_str("\n  caused by: ");
87            out.push_str(&layer.to_string());
88            cursor = layer.source();
89        }
90        out
91    }
92
93    /// Provider-supplied detail that `Display` deliberately withholds.
94    /// Currently the captured HTTP 400 response body. Operator-local
95    /// surfaces show this; logs/dumps keep using `Display`/`display_chain`
96    /// so the body never leaks server-side.
97    pub fn detail(&self) -> Option<&str> {
98        match self {
99            LlmError::BadRequest { body, .. } | LlmError::Api { body, .. } => Some(body),
100            _ => None,
101        }
102    }
103
104    /// Map a typed error to the telemetry taxonomy.
105    pub fn classify(&self) -> (LlmErrorClass, Option<u16>) {
106        match self {
107            LlmError::BadRequest { status, .. } => (LlmErrorClass::Other, Some(*status)),
108            LlmError::Api { status, .. } => (LlmErrorClass::Other, Some(*status)),
109            LlmError::RateLimit {
110                retry_after_ms: _,
111                status,
112            } => (LlmErrorClass::RateLimit, Some(*status)),
113            LlmError::PaymentRequired { status } => (LlmErrorClass::PaymentRequired, Some(*status)),
114            LlmError::ServerError { status } => (LlmErrorClass::ServerError, Some(*status)),
115            LlmError::ContextOverflow { .. } => (LlmErrorClass::ContextOverflow, None),
116            LlmError::Transport(_) => (LlmErrorClass::Transport, None),
117            LlmError::Parse(_) => (LlmErrorClass::Parse, None),
118            LlmError::Other(_) => (LlmErrorClass::Other, None),
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    /// Every variant maps to the telemetry class with the expected
128    /// http_status. Locks the bridge between AiModel error types and
129    /// the operator-facing telemetry taxonomy so a future variant
130    /// rename or class addition fails CI rather than silently
131    /// misclassifies in dashboards.
132    #[test]
133    fn classify_covers_every_variant_with_correct_status() {
134        let cases: Vec<(LlmError, LlmErrorClass, Option<u16>)> = vec![
135            (
136                LlmError::RateLimit {
137                    retry_after_ms: Some(1500),
138                    status: 429,
139                },
140                LlmErrorClass::RateLimit,
141                Some(429),
142            ),
143            (
144                LlmError::PaymentRequired { status: 402 },
145                LlmErrorClass::PaymentRequired,
146                Some(402),
147            ),
148            (
149                LlmError::ServerError { status: 503 },
150                LlmErrorClass::ServerError,
151                Some(503),
152            ),
153            (
154                LlmError::ContextOverflow {
155                    tokens: 9000,
156                    limit: 8192,
157                },
158                LlmErrorClass::ContextOverflow,
159                None,
160            ),
161            (
162                LlmError::Transport(Box::new(std::io::Error::new(
163                    std::io::ErrorKind::ConnectionReset,
164                    "reset",
165                ))),
166                LlmErrorClass::Transport,
167                None,
168            ),
169            (
170                LlmError::Parse(Box::new(std::io::Error::other("bad json"))),
171                LlmErrorClass::Parse,
172                None,
173            ),
174            (
175                LlmError::Other(Box::new(std::io::Error::other("misc"))),
176                LlmErrorClass::Other,
177                None,
178            ),
179            (
180                LlmError::BadRequest {
181                    status: 400,
182                    body: "{\"error\":\"bad schema\"}".to_string(),
183                },
184                LlmErrorClass::Other,
185                Some(400),
186            ),
187            (
188                LlmError::Api {
189                    status: 404,
190                    body: "{\"error\":\"No endpoints found for model\"}".to_string(),
191                },
192                LlmErrorClass::Other,
193                Some(404),
194            ),
195        ];
196        for (err, want_class, want_status) in cases {
197            let (got_class, got_status) = err.classify();
198            assert_eq!(
199                got_class, want_class,
200                "variant {err:?} classified wrong: got {got_class:?}, want {want_class:?}"
201            );
202            assert_eq!(
203                got_status, want_status,
204                "variant {err:?} status wrong: got {got_status:?}, want {want_status:?}"
205            );
206        }
207    }
208
209    /// `ContextOverflow` is the only variant that carries diagnostic
210    /// numbers but suppresses them from `classify` (the telemetry
211    /// schema doesn't have a tokens/limit field on `LlmRequestFailed`).
212    /// Lock that the variant is constructible with the expected fields
213    /// and that Display includes both numbers.
214    #[test]
215    fn context_overflow_carries_tokens_and_limit() {
216        let err = LlmError::ContextOverflow {
217            tokens: 12_500,
218            limit: 8_192,
219        };
220        let display = err.to_string();
221        assert!(display.contains("12500"), "display omits tokens: {display}");
222        assert!(display.contains("8192"), "display omits limit: {display}");
223        // The numeric fields stay readable for non-classify consumers.
224        if let LlmError::ContextOverflow { tokens, limit } = err {
225            assert_eq!(tokens, 12_500);
226            assert_eq!(limit, 8_192);
227        } else {
228            unreachable!("matched variant must extract fields");
229        }
230    }
231
232    /// `BadRequest` keeps the provider body out of `Display`/`display_chain`
233    /// (those reach logs + dumps and the body can echo the prompt) but
234    /// exposes it via `detail()` for operator-local surfaces.
235    #[test]
236    fn bad_request_body_hidden_from_display_exposed_via_detail() {
237        let body =
238            r#"{"error":{"message":"'max_tokens' too large: 16000 > 21000 - 5395","code":400}}"#;
239        let err = LlmError::BadRequest {
240            status: 400,
241            body: body.to_string(),
242        };
243        assert_eq!(err.to_string(), "bad request (status 400)");
244        assert!(
245            !err.display_chain().contains("max_tokens"),
246            "display_chain must not leak the body: {}",
247            err.display_chain()
248        );
249        assert_eq!(err.detail(), Some(body));
250        // Non-BadRequest variants carry no detail.
251        assert_eq!(LlmError::ServerError { status: 500 }.detail(), None);
252    }
253
254    /// `From<LlmError> for anyhow::Error` is the bridge used by the
255    /// agent's retry classifier (`downcast_ref::<LlmError>()`).
256    /// Verify the type survives the wrap.
257    #[test]
258    fn anyhow_wrap_preserves_typed_error() {
259        let err: anyhow::Error = LlmError::ServerError { status: 502 }.into();
260        let downcast = err.downcast_ref::<LlmError>();
261        assert!(matches!(
262            downcast,
263            Some(LlmError::ServerError { status: 502 })
264        ));
265    }
266
267    /// Single-layer variant (no `#[source]` wrapper) renders as the
268    /// thiserror `Display` text alone.
269    #[test]
270    fn display_chain_single_layer_emits_one_line() {
271        let chain = LlmError::ServerError { status: 503 }.display_chain();
272        assert_eq!(chain, "server error (status 503)");
273        assert!(
274            !chain.contains("caused by"),
275            "single-layer variant must not include a `caused by` line: {chain}"
276        );
277    }
278
279    /// Wrapped variant walks one `source` level and renders the
280    /// inner error on a `caused by` line.
281    #[test]
282    fn display_chain_two_layers_renders_caused_by() {
283        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset by peer");
284        let err = LlmError::Transport(Box::new(inner));
285        let chain = err.display_chain();
286        assert_eq!(chain, "transport\n  caused by: reset by peer");
287    }
288
289    /// Each `caused by` layer gets its own line; the root cause
290    /// appears last. This locks the rendering convention that log
291    /// shippers + dashboards depend on for stable splitting.
292    #[test]
293    fn display_chain_three_layers_walks_full_source_tree() {
294        // Build a triple-nested chain by hand: inner io::Error,
295        // wrapped in a SerdeJsonError-shaped layer, wrapped in
296        // LlmError::Parse.
297        #[derive(Debug)]
298        struct MidLayer(Box<dyn std::error::Error + Send + Sync + 'static>);
299        impl std::fmt::Display for MidLayer {
300            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301                write!(f, "mid layer")
302            }
303        }
304        impl std::error::Error for MidLayer {
305            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
306                Some(self.0.as_ref())
307            }
308        }
309        let root = std::io::Error::other("bad json byte");
310        let mid = MidLayer(Box::new(root));
311        let err = LlmError::Parse(Box::new(mid));
312        let chain = err.display_chain();
313        assert_eq!(
314            chain,
315            "parse\n  caused by: mid layer\n  caused by: bad json byte"
316        );
317    }
318
319    /// Variants that carry detail in the format string (vs `#[source]`)
320    /// still render their detail inline — the chain helper doesn't
321    /// strip information for them.
322    #[test]
323    fn display_chain_preserves_inline_detail_on_structured_variants() {
324        let chain = LlmError::ContextOverflow {
325            tokens: 9000,
326            limit: 8192,
327        }
328        .display_chain();
329        assert!(chain.contains("9000"), "tokens missing: {chain}");
330        assert!(chain.contains("8192"), "limit missing: {chain}");
331    }
332}