Skip to main content

inference_remote_core/
classify.rs

1//! HTTP-status โ†’ typed `InferenceError` classification.
2//!
3//! Doc ยง5.8 ("classify_error is provider-specific and produces typed
4//! errors"). This crate provides the *generic* table; per-provider
5//! crates layer on body-shape recognition (e.g. OpenAI's
6//! `error.code == "context_length_exceeded"` upgrade from a 400 to a
7//! `ContextLengthExceeded`).
8
9use std::time::Duration;
10
11use inference_core::error::InferenceError;
12use inference_core::runtime::ProviderKind;
13
14/// Map a status code (and optional `Retry-After` header) to a typed
15/// error. The body is captured for diagnostics. Per-provider crates
16/// post-process to refine specific shapes.
17pub fn classify_http_status(
18    provider: ProviderKind,
19    status: u16,
20    retry_after: Option<Duration>,
21    body: Option<String>,
22) -> InferenceError {
23    match status {
24        429 => InferenceError::RateLimited {
25            provider,
26            retry_after,
27        },
28        400 => InferenceError::BadRequest {
29            message: body.unwrap_or_else(|| "bad request".into()),
30        },
31        401 => InferenceError::Unauthorized {
32            message: body.unwrap_or_else(|| "unauthorized".into()),
33        },
34        403 => InferenceError::Forbidden {
35            message: body.unwrap_or_else(|| "forbidden".into()),
36        },
37        s if (500..600).contains(&s) => InferenceError::ServerError { status: s, body },
38        s => InferenceError::Internal(format!("unexpected status {s}: {body:?}")),
39    }
40}
41
42/// Parse a `Retry-After` header. Accepts either delta-seconds or an
43/// HTTP-date; on parse failure returns `None`.
44pub fn parse_retry_after(value: Option<&str>) -> Option<Duration> {
45    let v = value?;
46    if let Ok(secs) = v.trim().parse::<u64>() {
47        return Some(Duration::from_secs(secs));
48    }
49    // HTTP-date parsing โ€” keep it light, fall back to None on miss.
50    chrono::DateTime::parse_from_rfc2822(v.trim()).ok().and_then(|t| {
51        let now = chrono::Utc::now().timestamp();
52        let then = t.timestamp();
53        (then > now).then(|| Duration::from_secs((then - now) as u64))
54    })
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn delta_seconds_retry_after() {
63        assert_eq!(parse_retry_after(Some("12")), Some(Duration::from_secs(12)));
64        assert_eq!(parse_retry_after(Some("  3 ")), Some(Duration::from_secs(3)));
65        assert_eq!(parse_retry_after(None), None);
66        assert_eq!(parse_retry_after(Some("garbage")), None);
67    }
68
69    #[test]
70    fn classify_known_codes() {
71        let e = classify_http_status(ProviderKind::OpenAi, 429, Some(Duration::from_secs(2)), None);
72        assert!(matches!(e, InferenceError::RateLimited { .. }));
73        let e = classify_http_status(ProviderKind::Anthropic, 503, None, Some("oops".into()));
74        assert!(matches!(e, InferenceError::ServerError { status: 503, .. }));
75        let e = classify_http_status(ProviderKind::Gemini, 401, None, None);
76        assert!(matches!(e, InferenceError::Unauthorized { .. }));
77    }
78}