Skip to main content

openrouter/
error.rs

1//! Error model for the crate.
2
3use std::time::Duration;
4
5/// Convenient alias for `Result<T, openrouter::Error>`.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Top-level error type for all SDK operations.
9#[derive(thiserror::Error, Debug)]
10pub enum Error {
11    /// OpenRouter API returned a structured error response.
12    #[error("openrouter api error: {status} {code:?} — {message}")]
13    Api {
14        /// HTTP status code.
15        status: u16,
16        /// OpenRouter error code (e.g. `"invalid_request_error"`).
17        code: Option<String>,
18        /// Human-readable error message.
19        message: String,
20        /// Provider-supplied metadata (raw JSON).
21        metadata: Option<serde_json::Value>,
22        /// Provider name (when known).
23        provider: Option<String>,
24        /// `Retry-After` hint parsed from response headers.
25        retry_after: Option<Duration>,
26    },
27
28    /// HTTP transport-level failure.
29    #[error("http transport: {0}")]
30    Http(#[from] reqwest::Error),
31
32    /// JSON (de)serialization failed.
33    #[error("decode: {0}")]
34    Decode(#[from] serde_json::Error),
35
36    /// SSE / streaming-protocol failure.
37    #[error("stream: {0}")]
38    Stream(String),
39
40    /// Retry budget was exhausted; carries the last attempt's error.
41    #[error("retry exhausted after {attempts} attempt(s)")]
42    RetryExhausted {
43        /// Number of attempts made.
44        attempts: u32,
45        /// The error from the final attempt.
46        #[source]
47        source: Box<Error>,
48    },
49
50    /// Caller-supplied input failed validation.
51    #[error("invalid input: {0}")]
52    InvalidInput(&'static str),
53
54    /// Builder missing a required field.
55    #[error("builder: missing required field `{0}`")]
56    MissingField(&'static str),
57}
58
59#[derive(serde::Deserialize)]
60#[allow(dead_code)]
61struct ApiErrorEnvelope {
62    error: ApiErrorBody,
63}
64
65#[derive(serde::Deserialize)]
66struct ApiErrorBody {
67    #[serde(default)]
68    code: Option<serde_json::Value>,
69    #[serde(default)]
70    message: Option<String>,
71    #[serde(default)]
72    metadata: Option<serde_json::Value>,
73    #[serde(default)]
74    provider_name: Option<String>,
75}
76
77impl Error {
78    /// Build an `Error::Api` from a status code, raw response body, and optional
79    /// `Retry-After` hint. Tolerates non-JSON bodies and partial payloads.
80    pub(crate) fn from_response_body(
81        status: u16,
82        body: &[u8],
83        retry_after: Option<Duration>,
84    ) -> Error {
85        let parsed: Option<ApiErrorEnvelope> = serde_json::from_slice(body).ok();
86        let (code, message, metadata, provider) = match parsed {
87            Some(env) => {
88                let code = env.error.code.and_then(|v| match v {
89                    serde_json::Value::String(s) => Some(s),
90                    serde_json::Value::Number(n) => Some(n.to_string()),
91                    _ => None,
92                });
93                let message = env
94                    .error
95                    .message
96                    .unwrap_or_else(|| String::from_utf8_lossy(body).into_owned());
97                (code, message, env.error.metadata, env.error.provider_name)
98            }
99            None => (None, String::from_utf8_lossy(body).into_owned(), None, None),
100        };
101        Error::Api {
102            status,
103            code,
104            message,
105            metadata,
106            provider,
107            retry_after,
108        }
109    }
110
111    /// Whether this error is transient and worth retrying.
112    pub(crate) fn is_transient(&self) -> bool {
113        match self {
114            Error::Api { status, .. } => *status == 429 || (500..=599).contains(status),
115            Error::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
116            _ => false,
117        }
118    }
119
120    /// `Retry-After` hint, if any.
121    pub(crate) fn retry_after(&self) -> Option<Duration> {
122        match self {
123            Error::Api { retry_after, .. } => *retry_after,
124            _ => None,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn parses_structured_api_error() {
135        let body = br#"{"error":{"code":"invalid_request_error","message":"bad model","metadata":{"raw":"foo"},"provider_name":"openai"}}"#;
136        let err = Error::from_response_body(400, body, None);
137        match err {
138            Error::Api {
139                status,
140                code,
141                message,
142                provider,
143                metadata,
144                retry_after,
145            } => {
146                assert_eq!(status, 400);
147                assert_eq!(code.as_deref(), Some("invalid_request_error"));
148                assert_eq!(message, "bad model");
149                assert_eq!(provider.as_deref(), Some("openai"));
150                assert!(metadata.is_some());
151                assert!(retry_after.is_none());
152            }
153            _ => panic!("expected Api"),
154        }
155    }
156
157    #[test]
158    fn falls_back_to_raw_body_on_non_json() {
159        let err = Error::from_response_body(502, b"upstream gone", None);
160        match err {
161            Error::Api {
162                status,
163                message,
164                code,
165                ..
166            } => {
167                assert_eq!(status, 502);
168                assert_eq!(message, "upstream gone");
169                assert!(code.is_none());
170            }
171            _ => panic!("expected Api"),
172        }
173    }
174
175    #[test]
176    fn numeric_code_is_stringified() {
177        let body = br#"{"error":{"code":429,"message":"too many"}}"#;
178        let err = Error::from_response_body(429, body, Some(Duration::from_secs(3)));
179        if let Error::Api {
180            code, retry_after, ..
181        } = err
182        {
183            assert_eq!(code.as_deref(), Some("429"));
184            assert_eq!(retry_after, Some(Duration::from_secs(3)));
185        } else {
186            panic!("expected Api");
187        }
188    }
189
190    #[test]
191    fn is_transient_logic() {
192        let server = Error::Api {
193            status: 503,
194            code: None,
195            message: "x".into(),
196            metadata: None,
197            provider: None,
198            retry_after: None,
199        };
200        let rate = Error::Api {
201            status: 429,
202            code: None,
203            message: "x".into(),
204            metadata: None,
205            provider: None,
206            retry_after: None,
207        };
208        let bad = Error::Api {
209            status: 400,
210            code: None,
211            message: "x".into(),
212            metadata: None,
213            provider: None,
214            retry_after: None,
215        };
216        assert!(server.is_transient());
217        assert!(rate.is_transient());
218        assert!(!bad.is_transient());
219        assert!(!Error::InvalidInput("x").is_transient());
220    }
221
222    #[test]
223    fn display_does_not_panic() {
224        let e = Error::MissingField("api_key");
225        let _ = format!("{e}");
226    }
227}