Skip to main content

klieo_llm_common/
error.rs

1//! Shared error-mapping helpers used by every LLM provider crate.
2//!
3//! The full HTTP-status-→-`LlmError` mapping stays in each provider
4//! crate because the trailing message format embeds provider-specific
5//! detail (Anthropic `type=...`, Gemini `googleStatus=...`, etc.) and
6//! parity with existing wiremock tests is important.
7//!
8//! What's shared:
9//!
10//! - [`map_reqwest`] — `reqwest::Error` → [`LlmError`] dispatch. Identical
11//!   across all four providers before extraction.
12//! - [`retry_after_from_headers`] — RFC 7231 `Retry-After` parsing. The
13//!   per-provider `map_status` helpers previously hardcoded
14//!   `retry_after_secs = 1` and ignored the header — this fixes that
15//!   class of bug in one place.
16
17use klieo_core::error::LlmError;
18use reqwest::header::HeaderMap;
19
20/// Map a [`reqwest::Error`] into the right [`LlmError`] variant.
21///
22/// Distinguishes connect / timeout (transient) from request-shape
23/// errors (permanent) and leaves status-code mapping to each
24/// provider's `map_status`.
25///
26/// The `provider` argument is included in the rendered message so that
27/// downstream `tracing` filters and human-readable errors can identify
28/// which provider raised the failure.
29pub fn map_reqwest(provider: &str, error: reqwest::Error) -> LlmError {
30    if error.is_timeout() {
31        LlmError::Timeout
32    } else if error.is_connect() {
33        LlmError::Network(format!("{provider}: {error}"))
34    } else if error.is_decode() {
35        LlmError::Decoding(format!("{provider}: {error}"))
36    } else if error.is_request() || error.is_body() {
37        LlmError::BadRequest(format!("{provider}: {error}"))
38    } else if error.is_status() {
39        LlmError::Server(format!("{provider}: {error}"))
40    } else {
41        LlmError::Network(format!("{provider}: {error}"))
42    }
43}
44
45/// Selects which wording the [`map_status_code`] message builder produces, so a
46/// provider can phrase a handled error differently from one outside the ranges
47/// the ladder knows about. The builder receives this rather than the raw status
48/// to keep providers from re-deriving the range arithmetic.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum StatusMessageKind {
51    /// The status fell in a handled error range.
52    Recognised,
53    /// The status fell outside every handled range.
54    Unexpected,
55}
56
57/// Map an HTTP error status to the corresponding [`LlmError`], with the
58/// trailing message supplied by `message`.
59///
60/// Centralises the status-code dispatch that every provider's `map_status`
61/// otherwise duplicates. `message` is invoked at most once — only for the arms
62/// whose `LlmError` variant carries a detail string — and receives a
63/// [`StatusMessageKind`] so the provider can decode its own envelope and phrase
64/// the message without re-deriving the status ranges.
65pub fn map_status_code(
66    status: reqwest::StatusCode,
67    headers: &HeaderMap,
68    message: impl FnOnce(StatusMessageKind) -> String,
69) -> LlmError {
70    match status.as_u16() {
71        401 | 403 => LlmError::Unauthorized,
72        408 => LlmError::Timeout,
73        429 => LlmError::RateLimit {
74            retry_after_secs: retry_after_from_headers(headers),
75        },
76        s if (500..600).contains(&s) => LlmError::Server(message(StatusMessageKind::Recognised)),
77        s if (400..500).contains(&s) => {
78            LlmError::BadRequest(message(StatusMessageKind::Recognised))
79        }
80        _ => LlmError::Server(message(StatusMessageKind::Unexpected)),
81    }
82}
83
84/// Max characters of provider-supplied error detail (a response body or parsed
85/// envelope message) that may be embedded into an [`LlmError`]. Provider error
86/// responses can echo request content (e.g. the prompt on a validation failure),
87/// so bounding the length caps how much of that reaches logs / error messages
88/// while keeping enough to diagnose.
89const MAX_ERROR_DETAIL_CHARS: usize = 512;
90
91/// Appended when [`truncate_error_detail`] drops trailing content.
92const TRUNCATION_MARKER: &str = "…(truncated)";
93
94/// Bound a provider-supplied error detail to [`MAX_ERROR_DETAIL_CHARS`],
95/// appending [`TRUNCATION_MARKER`] when content is dropped. Truncates on a
96/// character boundary so the result is always valid UTF-8.
97pub fn truncate_error_detail(detail: &str) -> String {
98    if detail.chars().count() <= MAX_ERROR_DETAIL_CHARS {
99        return detail.to_string();
100    }
101    let truncated: String = detail.chars().take(MAX_ERROR_DETAIL_CHARS).collect();
102    format!("{truncated}{TRUNCATION_MARKER}")
103}
104
105const DEFAULT_RETRY_AFTER_SECS: u32 = 1;
106
107/// Parse the `Retry-After` header into a wait duration in seconds.
108///
109/// Returns 1 (`DEFAULT_RETRY_AFTER_SECS`) when the header is absent,
110/// malformed, or expresses an HTTP-date instead of a delta-seconds
111/// integer (the latter is rare in practice for LLM APIs and a default
112/// of 1 s keeps the existing pre-extraction behaviour).
113pub fn retry_after_from_headers(headers: &HeaderMap) -> u32 {
114    headers
115        .get(reqwest::header::RETRY_AFTER)
116        .and_then(|value| value.to_str().ok())
117        .and_then(|raw| raw.trim().parse::<u32>().ok())
118        .unwrap_or(DEFAULT_RETRY_AFTER_SECS)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use reqwest::header::{HeaderMap, HeaderValue};
125    use reqwest::StatusCode;
126
127    fn headers_with_retry_after(value: &str) -> HeaderMap {
128        let mut headers = HeaderMap::new();
129        headers.insert(
130            reqwest::header::RETRY_AFTER,
131            HeaderValue::from_str(value).unwrap(),
132        );
133        headers
134    }
135
136    #[test]
137    fn missing_retry_after_defaults_to_one_second() {
138        assert_eq!(retry_after_from_headers(&HeaderMap::new()), 1);
139    }
140
141    #[test]
142    fn integer_retry_after_parses() {
143        assert_eq!(retry_after_from_headers(&headers_with_retry_after("7")), 7);
144    }
145
146    #[test]
147    fn whitespace_padded_retry_after_parses() {
148        assert_eq!(
149            retry_after_from_headers(&headers_with_retry_after("  42  ")),
150            42
151        );
152    }
153
154    #[test]
155    fn http_date_retry_after_falls_back_to_default() {
156        let headers = headers_with_retry_after("Wed, 21 Oct 2026 07:28:00 GMT");
157        assert_eq!(retry_after_from_headers(&headers), 1);
158    }
159
160    #[test]
161    fn negative_retry_after_falls_back_to_default() {
162        assert_eq!(retry_after_from_headers(&headers_with_retry_after("-3")), 1);
163    }
164
165    fn detail(kind: StatusMessageKind) -> String {
166        match kind {
167            StatusMessageKind::Recognised => "recognised".to_string(),
168            StatusMessageKind::Unexpected => "unexpected".to_string(),
169        }
170    }
171
172    fn map(status: StatusCode) -> LlmError {
173        map_status_code(status, &HeaderMap::new(), detail)
174    }
175
176    #[test]
177    fn auth_statuses_map_to_unauthorized() {
178        assert!(matches!(
179            map(StatusCode::UNAUTHORIZED),
180            LlmError::Unauthorized
181        ));
182        assert!(matches!(map(StatusCode::FORBIDDEN), LlmError::Unauthorized));
183    }
184
185    #[test]
186    fn request_timeout_maps_to_timeout() {
187        assert!(matches!(
188            map(StatusCode::REQUEST_TIMEOUT),
189            LlmError::Timeout
190        ));
191    }
192
193    #[test]
194    fn too_many_requests_maps_to_rate_limit_with_header() {
195        let mut headers = HeaderMap::new();
196        headers.insert(reqwest::header::RETRY_AFTER, HeaderValue::from_static("23"));
197        match map_status_code(StatusCode::TOO_MANY_REQUESTS, &headers, detail) {
198            LlmError::RateLimit { retry_after_secs } => assert_eq!(retry_after_secs, 23),
199            other => panic!("expected RateLimit, got {other:?}"),
200        }
201    }
202
203    #[test]
204    fn server_5xx_uses_recognised_message() {
205        match map(StatusCode::INTERNAL_SERVER_ERROR) {
206            LlmError::Server(msg) => assert_eq!(msg, "recognised"),
207            other => panic!("expected Server, got {other:?}"),
208        }
209    }
210
211    #[test]
212    fn client_4xx_uses_recognised_message_as_bad_request() {
213        match map(StatusCode::BAD_REQUEST) {
214            LlmError::BadRequest(msg) => assert_eq!(msg, "recognised"),
215            other => panic!("expected BadRequest, got {other:?}"),
216        }
217    }
218
219    #[test]
220    fn out_of_range_status_uses_unexpected_message_as_server() {
221        // 3xx is outside the 4xx/5xx ranges the ladder handles.
222        match map(StatusCode::MOVED_PERMANENTLY) {
223            LlmError::Server(msg) => assert_eq!(msg, "unexpected"),
224            other => panic!("expected Server, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn short_error_detail_passes_through_unchanged() {
230        assert_eq!(truncate_error_detail("brief error"), "brief error");
231    }
232
233    #[test]
234    fn long_error_detail_is_truncated_with_marker() {
235        let long = "x".repeat(MAX_ERROR_DETAIL_CHARS + 100);
236        let out = truncate_error_detail(&long);
237        assert!(out.ends_with("…(truncated)"));
238        assert_eq!(
239            out.chars().filter(|c| *c == 'x').count(),
240            MAX_ERROR_DETAIL_CHARS
241        );
242    }
243
244    #[test]
245    fn truncation_respects_char_boundaries() {
246        // Multibyte chars must not be split mid-codepoint (no panic, valid UTF-8).
247        let long = "é".repeat(MAX_ERROR_DETAIL_CHARS + 10);
248        let out = truncate_error_detail(&long);
249        assert!(out.starts_with('é'));
250        assert!(out.ends_with("…(truncated)"));
251    }
252}