klieo_llm_common/
error.rs1use klieo_core::error::LlmError;
18use reqwest::header::HeaderMap;
19
20pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum StatusMessageKind {
51 Recognised,
53 Unexpected,
55}
56
57pub 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
84const MAX_ERROR_DETAIL_CHARS: usize = 512;
90
91const TRUNCATION_MARKER: &str = "…(truncated)";
93
94pub 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
107pub 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 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 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}