Skip to main content

oxi_ai/
error.rs

1//! Error types for oxi-ai
2
3use thiserror::Error;
4
5/// Structured HTTP error detail.
6///
7/// omp aligns per-provider error classes — `AnthropicApiError` carries a
8/// `request-id`, `OpenAIHttpError` parses the body envelope, etc. This struct
9/// captures the common structured fields so callers inspect provider/error
10/// identity directly instead of parsing a flat `(u16, String)` tuple.
11#[derive(Debug, Clone)]
12pub struct HttpErrorDetail {
13    /// HTTP status code.
14    pub status: u16,
15    /// Raw response body.
16    pub body: String,
17    /// Provider id (e.g. `"anthropic"`, `"openai"`, `"deepseek"`), if known.
18    pub provider: Option<String>,
19    /// Provider request id (Anthropic `request-id`, OpenAI `x-request-id`, …).
20    pub request_id: Option<String>,
21}
22
23impl HttpErrorDetail {
24    /// Minimal detail from a status code and response body.
25    pub fn new(status: u16, body: String) -> Self {
26        Self {
27            status,
28            body,
29            provider: None,
30            request_id: None,
31        }
32    }
33
34    /// Attach the provider id.
35    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
36        self.provider = Some(provider.into());
37        self
38    }
39
40    /// Attach a provider request id (e.g. parsed from a response header).
41    pub fn with_request_id(mut self, request_id: Option<String>) -> Self {
42        self.request_id = request_id;
43        self
44    }
45}
46
47impl std::fmt::Display for HttpErrorDetail {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "HTTP error {}: {}", self.status, self.body)?;
50        if let Some(provider) = &self.provider {
51            write!(f, " [{provider}]")?;
52        }
53        if let Some(id) = &self.request_id {
54            write!(f, " (request-id: {id})")?;
55        }
56        Ok(())
57    }
58}
59
60/// Provider-specific errors. `#[non_exhaustive]` — consumers MUST add a
61/// catch-all `_ =>` arm in their `match` expressions. Existing named variants
62/// are frozen; their meaning does not change between releases (see
63/// `docs/release-process.md`).
64#[derive(Error, Debug)]
65#[non_exhaustive]
66pub enum ProviderError {
67    /// API key is missing.
68    #[error("Missing API key")]
69    MissingApiKey,
70
71    /// Unknown provider.
72    #[error("Unknown provider: {0}")]
73    UnknownProvider(String),
74
75    /// Provider not yet implemented.
76    #[error("Provider not implemented: {0}")]
77    NotImplemented(String),
78
79    /// HTTP error with structured detail (status, body, provider, request-id).
80    #[error("{0}")]
81    HttpError(HttpErrorDetail),
82
83    /// HTTP request failed.
84    #[error("Request failed: {0}")]
85    RequestFailed(#[from] reqwest::Error),
86
87    /// I/O error.
88    #[error("IO error: {0}")]
89    IoError(#[from] std::io::Error),
90
91    /// Invalid response from provider.
92    #[error("Invalid response: {0}")]
93    InvalidResponse(String),
94
95    /// Invalid API key format.
96    #[error("Invalid API key format")]
97    InvalidApiKey,
98
99    /// JSON parsing error.
100    #[error("JSON parse error: {0}")]
101    JsonParse(#[from] serde_json::Error),
102
103    /// Streaming error.
104    #[error("Stream error: {0}")]
105    StreamError(String),
106
107    /// Network error.
108    #[error("Network error: {0}")]
109    NetworkError(String),
110
111    /// Context window overflow.
112    #[error("Context overflow")]
113    ContextOverflow,
114
115    /// Request timed out.
116    #[error("Request timed out")]
117    Timeout,
118
119    /// Rate limit exceeded.
120    #[error("Rate limited")]
121    RateLimited {
122        /// Wait time suggested by the server.
123        retry_after: Option<std::time::Duration>,
124    },
125}
126
127impl ProviderError {
128    /// Returns whether this error is retryable.
129    pub fn is_retryable(&self) -> bool {
130        match self {
131            Self::HttpError(detail) => detail.status == 429 || detail.status >= 500,
132            Self::NetworkError(_) => true,
133            Self::Timeout => true,
134            Self::RateLimited { .. } => true,
135            _ => false,
136        }
137    }
138
139    /// Returns the retry wait time suggested by the server.
140    pub fn retry_after(&self) -> Option<std::time::Duration> {
141        match self {
142            Self::RateLimited { retry_after } => *retry_after,
143            Self::HttpError(detail) if detail.status == 429 => {
144                Some(std::time::Duration::from_secs(5))
145            }
146            _ => None,
147        }
148    }
149
150    /// Returns the HTTP status code if this is an HTTP error, else `None`.
151    ///
152    /// Convenience for call sites that previously destructured the old
153    /// `HttpError(u16, String)` tuple (e.g. inside `matches!`, which cannot
154    /// carry a guard).
155    pub fn http_status(&self) -> Option<u16> {
156        match self {
157            Self::HttpError(detail) => Some(detail.status),
158            _ => None,
159        }
160    }
161}
162
163/// Validation errors
164#[derive(Error, Debug)]
165pub enum ValidationError {
166    #[error("Invalid JSON: {0}")]
167    InvalidJson(#[from] serde_json::Error),
168
169    #[error("Schema validation failed: {0}")]
170    SchemaValidation(String),
171
172    #[error("Missing required field: {0}")]
173    MissingRequiredField(String),
174}
175
176/// Unified error type for oxi-ai
177#[derive(Error, Debug)]
178pub enum Error {
179    /// Wraps a provider error.
180    #[error("Provider error: {0}")]
181    Provider(#[from] ProviderError),
182
183    /// Wraps a validation error.
184    #[error("Validation error: {0}")]
185    Validation(#[from] ValidationError),
186
187    /// Wraps an I/O error.
188    #[error("IO error: {0}")]
189    Io(#[from] std::io::Error),
190}
191
192/// Result type alias
193pub type Result<T> = std::result::Result<T, Error>;
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn provider_error_display() {
201        assert_eq!(ProviderError::MissingApiKey.to_string(), "Missing API key");
202        assert_eq!(
203            ProviderError::UnknownProvider("foo".to_string()).to_string(),
204            "Unknown provider: foo"
205        );
206        assert_eq!(
207            ProviderError::HttpError(HttpErrorDetail::new(429, "rate limited".to_string()))
208                .to_string(),
209            "HTTP error 429: rate limited"
210        );
211        // Structured detail surfaces provider + request-id (omp AnthropicApiError align).
212        assert_eq!(
213            ProviderError::HttpError(
214                HttpErrorDetail::new(500, "boom".to_string())
215                    .with_provider("anthropic")
216                    .with_request_id(Some("req_123".to_string()))
217            )
218            .to_string(),
219            "HTTP error 500: boom [anthropic] (request-id: req_123)"
220        );
221        assert_eq!(
222            ProviderError::InvalidResponse("bad json".to_string()).to_string(),
223            "Invalid response: bad json"
224        );
225        assert_eq!(
226            ProviderError::StreamError("disconnected".to_string()).to_string(),
227            "Stream error: disconnected"
228        );
229        assert_eq!(
230            ProviderError::NotImplemented("x".to_string()).to_string(),
231            "Provider not implemented: x"
232        );
233    }
234
235    #[test]
236    fn error_chain_from_provider_error() {
237        let inner = ProviderError::MissingApiKey;
238        let outer: Error = inner.into();
239        assert!(matches!(
240            outer,
241            Error::Provider(ProviderError::MissingApiKey)
242        ));
243        assert!(outer.to_string().contains("Missing API key"));
244    }
245
246    #[test]
247    fn validation_error_display() {
248        let err = ValidationError::MissingRequiredField("model".to_string());
249        assert_eq!(err.to_string(), "Missing required field: model");
250    }
251
252    #[test]
253    fn error_chain_from_io() {
254        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
255        let outer: Error = io_err.into();
256        assert!(matches!(outer, Error::Io(_)));
257    }
258}