1use std::time::Duration;
4
5pub type Result<T> = std::result::Result<T, Error>;
7
8#[derive(thiserror::Error, Debug)]
10pub enum Error {
11 #[error("openrouter api error: {status} {code:?} — {message}")]
13 Api {
14 status: u16,
16 code: Option<String>,
18 message: String,
20 metadata: Option<serde_json::Value>,
22 provider: Option<String>,
24 retry_after: Option<Duration>,
26 },
27
28 #[error("http transport: {0}")]
30 Http(#[from] reqwest::Error),
31
32 #[error("browser transport: {0}")]
34 BrowserTransport(String),
35
36 #[error("decode: {0}")]
38 Decode(#[from] serde_json::Error),
39
40 #[error("stream: {0}")]
42 Stream(String),
43
44 #[error("retry exhausted after {attempts} attempt(s)")]
46 RetryExhausted {
47 attempts: u32,
49 #[source]
51 source: Box<Error>,
52 },
53
54 #[error("invalid input: {0}")]
56 InvalidInput(&'static str),
57
58 #[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 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 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 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}