1use std::fmt;
20
21use crate::backend_error::BackendErrorKind;
22
23#[derive(Debug, Clone)]
30pub enum BackendError {
31 RateLimit {
33 provider: String,
34 model: String,
35 retry_after_seconds: Option<u64>,
36 body_preview: String,
37 },
38 Auth {
40 provider: String,
41 model: String,
42 api_key_env: Option<String>,
43 status: u16,
44 body_preview: String,
45 },
46 ContextLength {
49 provider: String,
50 model: String,
51 body_preview: String,
52 },
53 SafetyBreach {
55 provider: String,
56 model: String,
57 finish_reason: String,
58 body_preview: String,
59 },
60 ModelNotFound {
62 provider: String,
63 model: String,
64 status: u16,
65 body_preview: String,
66 },
67 Generic {
69 provider: String,
70 model: String,
71 status: Option<u16>,
72 message: String,
73 },
74}
75
76impl BackendError {
77 pub fn provider(&self) -> &str {
79 match self {
80 Self::RateLimit { provider, .. }
81 | Self::Auth { provider, .. }
82 | Self::ContextLength { provider, .. }
83 | Self::SafetyBreach { provider, .. }
84 | Self::ModelNotFound { provider, .. }
85 | Self::Generic { provider, .. } => provider,
86 }
87 }
88
89 pub fn model(&self) -> &str {
92 match self {
93 Self::RateLimit { model, .. }
94 | Self::Auth { model, .. }
95 | Self::ContextLength { model, .. }
96 | Self::SafetyBreach { model, .. }
97 | Self::ModelNotFound { model, .. }
98 | Self::Generic { model, .. } => model,
99 }
100 }
101
102 pub fn kind(&self) -> BackendErrorKind {
107 match self {
108 Self::RateLimit { retry_after_seconds, .. } => BackendErrorKind::RateLimit {
109 retry_after: retry_after_seconds.map(std::time::Duration::from_secs),
110 },
111 Self::Auth { .. } => BackendErrorKind::AuthError,
112 Self::ContextLength { .. } => BackendErrorKind::Unknown, Self::SafetyBreach { .. } => BackendErrorKind::Unknown, Self::ModelNotFound { .. } => BackendErrorKind::Unknown, Self::Generic { status, .. } => match status {
116 Some(s) if (500..600).contains(s) => BackendErrorKind::ServerError { status: *s },
117 Some(429) => BackendErrorKind::RateLimit { retry_after: None },
118 Some(401) | Some(403) => BackendErrorKind::AuthError,
119 Some(408) => BackendErrorKind::Timeout,
120 Some(_) => BackendErrorKind::Unknown,
121 None => BackendErrorKind::NetworkError,
122 },
123 }
124 }
125
126 pub fn is_retryable(&self) -> bool {
130 self.kind().is_retryable()
131 }
132
133 pub fn category(&self) -> &'static str {
136 match self {
137 Self::RateLimit { .. } => "rate_limit",
138 Self::Auth { .. } => "auth_error",
139 Self::ContextLength { .. } => "context_length_exceeded",
140 Self::SafetyBreach { .. } => "safety_breach",
141 Self::ModelNotFound { .. } => "model_not_found",
142 Self::Generic { .. } => "model_call_error",
143 }
144 }
145}
146
147impl fmt::Display for BackendError {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 Self::RateLimit {
151 provider,
152 model,
153 retry_after_seconds,
154 body_preview,
155 } => {
156 let retry_after_part = retry_after_seconds
157 .map(|s| format!(", retry_after={}s", s))
158 .unwrap_or_default();
159 write!(
160 f,
161 "Rate limit on provider {provider:?} (model={model:?}, status=429{retry_after_part}). \
162 Retries exhausted. Body: {body_preview}"
163 )
164 }
165 Self::Auth {
166 provider,
167 model,
168 api_key_env,
169 status,
170 body_preview,
171 } => {
172 let env_hint = api_key_env
173 .as_ref()
174 .map(|env| format!(" (env var: {env})"))
175 .unwrap_or_default();
176 write!(
177 f,
178 "Authentication failed on provider {provider:?}{env_hint}, \
179 status={status}. Verify the API key is set, valid, and has \
180 access to model {model:?}. Body: {body_preview}"
181 )
182 }
183 Self::ContextLength {
184 provider,
185 model,
186 body_preview,
187 } => write!(
188 f,
189 "Prompt exceeds context window of model {model:?} on provider \
190 {provider:?} (status=400). Body: {body_preview}"
191 ),
192 Self::SafetyBreach {
193 provider,
194 model,
195 finish_reason,
196 body_preview,
197 } => write!(
198 f,
199 "Provider {provider:?} content filter blocked the request \
200 (model={model:?}, finish_reason={finish_reason:?}). Body: {body_preview}"
201 ),
202 Self::ModelNotFound {
203 provider,
204 model,
205 status,
206 body_preview,
207 } => write!(
208 f,
209 "Model {model:?} not found at provider {provider:?} (status={status}). \
210 Either the slug is mistyped or the model was deprecated. Body: {body_preview}"
211 ),
212 Self::Generic {
213 provider,
214 model,
215 status,
216 message,
217 } => {
218 let status_part = status
219 .map(|s| format!("HTTP {s}"))
220 .unwrap_or_else(|| "transport error".to_string());
221 write!(
222 f,
223 "Provider {provider:?} returned {status_part} for model \
224 {model:?}. {message}"
225 )
226 }
227 }
228 }
229}
230
231impl std::error::Error for BackendError {}
232
233pub fn categorise_http(
237 provider: &str,
238 model: &str,
239 status: u16,
240 headers: &reqwest::header::HeaderMap,
241 body: &str,
242 api_key_env: Option<&str>,
243) -> BackendError {
244 let body_preview: String = body.chars().take(200).collect();
245 let body_lower = body.to_lowercase();
246
247 if status == 429 {
248 let retry_after = headers
249 .get("retry-after")
250 .and_then(|v| v.to_str().ok())
251 .and_then(|s| s.trim().parse::<u64>().ok());
252 return BackendError::RateLimit {
253 provider: provider.to_string(),
254 model: model.to_string(),
255 retry_after_seconds: retry_after,
256 body_preview,
257 };
258 }
259
260 if status == 401 || status == 403 {
261 return BackendError::Auth {
262 provider: provider.to_string(),
263 model: model.to_string(),
264 api_key_env: api_key_env.map(str::to_string),
265 status,
266 body_preview,
267 };
268 }
269
270 if status == 404 {
271 return BackendError::ModelNotFound {
272 provider: provider.to_string(),
273 model: model.to_string(),
274 status,
275 body_preview,
276 };
277 }
278
279 if status == 400 {
280 if body_lower.contains("context_length")
283 || body_lower.contains("context length")
284 || body_lower.contains("maximum context")
285 || body_lower.contains("too long")
286 {
287 return BackendError::ContextLength {
288 provider: provider.to_string(),
289 model: model.to_string(),
290 body_preview,
291 };
292 }
293 if body_lower.contains("model_not_found")
294 || body_lower.contains("model not found")
295 || body_lower.contains("no such model")
296 {
297 return BackendError::ModelNotFound {
298 provider: provider.to_string(),
299 model: model.to_string(),
300 status,
301 body_preview,
302 };
303 }
304 }
305
306 BackendError::Generic {
307 provider: provider.to_string(),
308 model: model.to_string(),
309 status: Some(status),
310 message: format!("Body: {body_preview}"),
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use reqwest::header::HeaderMap;
318
319 fn empty_headers() -> HeaderMap {
320 HeaderMap::new()
321 }
322
323 #[test]
324 fn ratelimit_carries_retry_after() {
325 let mut h = HeaderMap::new();
326 h.insert("retry-after", "60".parse().unwrap());
327 let err = categorise_http("anthropic", "claude-x", 429, &h, "rate-limited", None);
328 assert!(matches!(err, BackendError::RateLimit { retry_after_seconds: Some(60), .. }));
329 assert_eq!(err.category(), "rate_limit");
330 assert!(err.is_retryable());
331 }
332
333 #[test]
334 fn ratelimit_without_header_is_still_classified() {
335 let err = categorise_http("openai", "gpt-x", 429, &empty_headers(), "no body", None);
336 match err {
337 BackendError::RateLimit { retry_after_seconds, .. } => {
338 assert!(retry_after_seconds.is_none());
339 }
340 _ => panic!("expected RateLimit"),
341 }
342 }
343
344 #[test]
345 fn auth_401_with_env_hint() {
346 let err = categorise_http(
347 "kimi",
348 "kimi-k2.6",
349 401,
350 &empty_headers(),
351 "unauthorized",
352 Some("AXON_KIMI_API_KEY"),
353 );
354 match err {
355 BackendError::Auth { api_key_env, status, .. } => {
356 assert_eq!(api_key_env.as_deref(), Some("AXON_KIMI_API_KEY"));
357 assert_eq!(status, 401);
358 }
359 _ => panic!("expected Auth"),
360 }
361 }
362
363 #[test]
364 fn auth_403_also_classified_as_auth() {
365 let err = categorise_http("openai", "gpt-x", 403, &empty_headers(), "", None);
366 assert!(matches!(err, BackendError::Auth { status: 403, .. }));
367 }
368
369 #[test]
370 fn model_not_found_404() {
371 let err = categorise_http("openai", "gpt-3.999", 404, &empty_headers(), "", None);
372 assert!(matches!(err, BackendError::ModelNotFound { .. }));
373 assert!(!err.is_retryable()); }
375
376 #[test]
377 fn context_length_400_with_oai_marker() {
378 let body = r#"{"error":{"code":"context_length_exceeded","message":"prompt too long"}}"#;
379 let err = categorise_http("openai", "gpt-x", 400, &empty_headers(), body, None);
380 assert!(matches!(err, BackendError::ContextLength { .. }));
381 }
382
383 #[test]
384 fn context_length_400_with_anthropic_marker() {
385 let body = "the prompt is too long for this model's maximum context";
386 let err = categorise_http("anthropic", "claude-x", 400, &empty_headers(), body, None);
387 assert!(matches!(err, BackendError::ContextLength { .. }));
388 }
389
390 #[test]
391 fn model_not_found_400_with_marker() {
392 let body = r#"{"error":{"code":"model_not_found"}}"#;
393 let err = categorise_http("openai", "gpt-y", 400, &empty_headers(), body, None);
394 assert!(matches!(err, BackendError::ModelNotFound { status: 400, .. }));
395 }
396
397 #[test]
398 fn generic_500_is_retryable() {
399 let err = categorise_http("openai", "gpt-x", 500, &empty_headers(), "boom", None);
400 assert!(matches!(err, BackendError::Generic { status: Some(500), .. }));
401 assert!(err.is_retryable());
402 }
403
404 #[test]
405 fn generic_502_is_retryable() {
406 let err = categorise_http("openai", "gpt-x", 502, &empty_headers(), "", None);
407 assert!(err.is_retryable());
408 }
409
410 #[test]
411 fn generic_400_unmapped_is_not_retryable() {
412 let err = categorise_http("openai", "gpt-x", 400, &empty_headers(), "weird", None);
413 assert!(matches!(err, BackendError::Generic { .. }));
414 assert!(!err.is_retryable());
415 }
416
417 #[test]
418 fn provider_and_model_accessors() {
419 let err = categorise_http("kimi", "kimi-k2.6", 429, &empty_headers(), "", None);
420 assert_eq!(err.provider(), "kimi");
421 assert_eq!(err.model(), "kimi-k2.6");
422 }
423
424 #[test]
425 fn body_preview_truncated_to_200_chars() {
426 let body = "x".repeat(500);
427 let err = categorise_http("openai", "gpt-x", 500, &empty_headers(), &body, None);
428 match err {
429 BackendError::Generic { message, .. } => {
430 assert!(message.starts_with("Body: "));
432 let preview = &message["Body: ".len()..];
433 assert_eq!(preview.len(), 200);
434 }
435 _ => panic!("expected Generic"),
436 }
437 }
438
439 #[test]
440 fn display_includes_provider_and_status() {
441 let err = categorise_http("anthropic", "claude-x", 429, &empty_headers(), "tx", None);
442 let s = format!("{err}");
443 assert!(s.contains("anthropic"));
444 assert!(s.contains("claude-x"));
445 assert!(s.contains("429"));
446 }
447
448 #[test]
449 fn safety_breach_constructed_directly() {
450 let err = BackendError::SafetyBreach {
455 provider: "openai".to_string(),
456 model: "gpt-4o".to_string(),
457 finish_reason: "content_filter".to_string(),
458 body_preview: "{}".to_string(),
459 };
460 assert_eq!(err.category(), "safety_breach");
461 assert!(!err.is_retryable());
462 let msg = format!("{err}");
463 assert!(msg.contains("content_filter"));
464 }
465}