Skip to main content

openai_compat/
error.rs

1//! Error types mirroring the `openai-python` exception hierarchy
2//! (`_exceptions.py`): status-code specific API errors, connection/timeout
3//! errors, and configuration errors.
4
5use serde::{Deserialize, Deserializer};
6
7/// Structured error detail returned by the API inside `{"error": {...}}`.
8#[derive(Debug, Clone, Default, Deserialize)]
9pub struct ApiErrorDetail {
10    /// Human-readable error description.
11    pub message: Option<String>,
12    /// Error type, e.g. `invalid_request_error`.
13    #[serde(rename = "type")]
14    pub error_type: Option<String>,
15    /// The request parameter that caused the error, if any.
16    pub param: Option<String>,
17    /// Machine-readable error code. Some providers send numbers, so this
18    /// accepts both strings and numbers.
19    #[serde(default, deserialize_with = "lenient_string")]
20    pub code: Option<String>,
21}
22
23fn lenient_string<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
24    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
25    Ok(match value {
26        None | Some(serde_json::Value::Null) => None,
27        Some(serde_json::Value::String(s)) => Some(s),
28        Some(other) => Some(other.to_string()),
29    })
30}
31
32/// Classification of an API error by HTTP status code, mirroring the
33/// `APIStatusError` subclasses in `_exceptions.py`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[non_exhaustive]
36pub enum ApiErrorKind {
37    /// 400
38    BadRequest,
39    /// 401
40    Authentication,
41    /// 403
42    PermissionDenied,
43    /// 404
44    NotFound,
45    /// 409
46    Conflict,
47    /// 422
48    UnprocessableEntity,
49    /// 429
50    RateLimit,
51    /// 5xx
52    InternalServer,
53    /// Any other non-success status.
54    Other,
55}
56
57impl ApiErrorKind {
58    pub fn from_status(status: u16) -> Self {
59        match status {
60            400 => Self::BadRequest,
61            401 => Self::Authentication,
62            403 => Self::PermissionDenied,
63            404 => Self::NotFound,
64            409 => Self::Conflict,
65            422 => Self::UnprocessableEntity,
66            429 => Self::RateLimit,
67            s if s >= 500 => Self::InternalServer,
68            _ => Self::Other,
69        }
70    }
71}
72
73/// An error response (4xx/5xx) from the API.
74#[derive(Debug, Clone)]
75pub struct ApiError {
76    /// HTTP status code.
77    pub status: u16,
78    /// Status-code classification.
79    pub kind: ApiErrorKind,
80    /// Full error message, `Error code: {status} - {body}`.
81    pub message: String,
82    /// Parsed `{"error": {...}}` body, when present.
83    pub detail: Option<ApiErrorDetail>,
84    /// Value of the `x-request-id` response header.
85    pub request_id: Option<String>,
86}
87
88impl ApiError {
89    /// Whether this status code is retryable (408/409/429/5xx), mirroring
90    /// `_base_client.py::_should_retry`.
91    pub fn is_retryable(&self) -> bool {
92        matches!(self.status, 408 | 409 | 429) || self.status >= 500
93    }
94}
95
96impl std::fmt::Display for ApiError {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.write_str(&self.message)?;
99        if let Some(id) = &self.request_id {
100            write!(f, " (request_id: {id})")?;
101        }
102        Ok(())
103    }
104}
105
106/// All errors produced by this crate.
107#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum OpenAIError {
110    /// Client construction / configuration problem (e.g. missing API key).
111    #[error("configuration error: {0}")]
112    Config(String),
113    /// Failed to reach the server.
114    #[error("connection error: {0}")]
115    Connection(String),
116    /// The request timed out.
117    #[error("request timed out")]
118    Timeout,
119    /// The API returned a 4xx/5xx response.
120    /// (Boxed to keep `Result` sizes small.)
121    #[error("{0}")]
122    Api(Box<ApiError>),
123    /// An error occurred while consuming a streaming response.
124    #[error("stream error: {0}")]
125    Stream(String),
126    /// Failed to (de)serialize JSON.
127    #[error("JSON error: {0}")]
128    Json(#[from] serde_json::Error),
129    /// Any other HTTP-level failure.
130    #[error("HTTP error: {0}")]
131    Http(#[from] reqwest::Error),
132}
133
134impl OpenAIError {
135    /// Build an [`OpenAIError::Api`] from an error response, mirroring
136    /// `_base_client.py::_make_status_error_from_response`: the body is parsed
137    /// as JSON and the nested `error` object extracted when present.
138    pub(crate) fn from_response(status: u16, request_id: Option<String>, body: &str) -> Self {
139        let body = body.trim();
140        let json = serde_json::from_str::<serde_json::Value>(body).ok();
141        let detail = json
142            .as_ref()
143            .and_then(|v| {
144                let error = v.get("error").cloned().unwrap_or_else(|| v.clone());
145                serde_json::from_value::<ApiErrorDetail>(error).ok()
146            })
147            .filter(|d| {
148                d.message.is_some() || d.error_type.is_some() || d.param.is_some() || d.code.is_some()
149            });
150
151        // Mirror `_base_client._make_status_error_from_response`: JSON bodies
152        // get the `Error code:` prefix; non-JSON bodies are used verbatim.
153        let message = if json.is_some() {
154            format!("Error code: {status} - {body}")
155        } else if body.is_empty() {
156            format!("Error code: {status}")
157        } else {
158            body.to_string()
159        };
160
161        Self::Api(Box::new(ApiError {
162            status,
163            kind: ApiErrorKind::from_status(status),
164            message,
165            detail,
166            request_id,
167        }))
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn maps_statuses_to_kinds() {
177        assert_eq!(ApiErrorKind::from_status(400), ApiErrorKind::BadRequest);
178        assert_eq!(ApiErrorKind::from_status(401), ApiErrorKind::Authentication);
179        assert_eq!(ApiErrorKind::from_status(403), ApiErrorKind::PermissionDenied);
180        assert_eq!(ApiErrorKind::from_status(404), ApiErrorKind::NotFound);
181        assert_eq!(ApiErrorKind::from_status(409), ApiErrorKind::Conflict);
182        assert_eq!(ApiErrorKind::from_status(422), ApiErrorKind::UnprocessableEntity);
183        assert_eq!(ApiErrorKind::from_status(429), ApiErrorKind::RateLimit);
184        assert_eq!(ApiErrorKind::from_status(500), ApiErrorKind::InternalServer);
185        assert_eq!(ApiErrorKind::from_status(503), ApiErrorKind::InternalServer);
186        assert_eq!(ApiErrorKind::from_status(418), ApiErrorKind::Other);
187    }
188
189    #[test]
190    fn parses_error_body() {
191        let body = r#"{"error": {"message": "Invalid API key", "type": "invalid_request_error", "param": null, "code": "invalid_api_key"}}"#;
192        let err = OpenAIError::from_response(401, Some("req_123".into()), body);
193        let OpenAIError::Api(api) = err else { panic!("expected Api error") };
194        assert_eq!(api.status, 401);
195        assert_eq!(api.kind, ApiErrorKind::Authentication);
196        assert_eq!(api.request_id.as_deref(), Some("req_123"));
197        assert!(!api.is_retryable());
198        let detail = api.detail.expect("detail parsed");
199        assert_eq!(detail.message.as_deref(), Some("Invalid API key"));
200        assert_eq!(detail.code.as_deref(), Some("invalid_api_key"));
201    }
202
203    #[test]
204    fn handles_non_json_body_and_numeric_code() {
205        let err = OpenAIError::from_response(502, None, "Bad Gateway");
206        let OpenAIError::Api(api) = err else { panic!() };
207        assert!(api.detail.is_none());
208        assert!(api.is_retryable());
209        // Non-JSON bodies are used verbatim (mirrors the Python SDK).
210        assert_eq!(api.message, "Bad Gateway");
211
212        let err = OpenAIError::from_response(502, None, "");
213        let OpenAIError::Api(api) = err else { panic!() };
214        assert_eq!(api.message, "Error code: 502");
215
216        let err = OpenAIError::from_response(429, None, r#"{"error": {"message": "slow down", "code": 42}}"#);
217        let OpenAIError::Api(api) = err else { panic!() };
218        assert_eq!(api.detail.unwrap().code.as_deref(), Some("42"));
219    }
220}