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}