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