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("decode: {0}")]
34 Decode(#[from] serde_json::Error),
35
36 #[error("stream: {0}")]
38 Stream(String),
39
40 #[error("retry exhausted after {attempts} attempt(s)")]
42 RetryExhausted {
43 attempts: u32,
45 #[source]
47 source: Box<Error>,
48 },
49
50 #[error("invalid input: {0}")]
52 InvalidInput(&'static str),
53
54 #[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 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 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 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}