1use http::HeaderMap;
2use serde_json::Value;
3
4#[derive(Debug, thiserror::Error)]
10pub enum OpencodeError {
11 #[error("{status} {message}")]
13 Api { status: u16, headers: Option<Box<HeaderMap>>, body: Option<Box<Value>>, message: String },
14
15 #[error("Connection error: {message}")]
17 Connection {
18 message: String,
19 #[source]
20 source: Option<Box<dyn std::error::Error + Send + Sync>>,
21 },
22
23 #[error("Request timed out.")]
25 Timeout,
26
27 #[error("Request was aborted.")]
29 UserAbort,
30
31 #[error("Serialization error: {0}")]
33 Serialization(#[from] serde_json::Error),
34
35 #[error("HTTP error: {0}")]
37 Http(#[source] Box<dyn std::error::Error + Send + Sync>),
38}
39
40impl OpencodeError {
41 pub const fn status(&self) -> Option<u16> {
45 match self {
46 Self::Api { status, .. } => Some(*status),
47 _ => None,
48 }
49 }
50
51 pub const fn is_retryable(&self) -> bool {
58 match self {
59 Self::Api { status, .. } => matches!(*status, 408 | 409 | 429) || *status >= 500,
60 Self::Connection { .. } | Self::Timeout => true,
61 Self::UserAbort | Self::Serialization(_) | Self::Http(_) => false,
62 }
63 }
64
65 pub const fn is_timeout(&self) -> bool {
67 matches!(self, Self::Timeout)
68 }
69
70 pub fn bad_request(
74 headers: Option<HeaderMap>,
75 body: Option<Value>,
76 message: impl Into<String>,
77 ) -> Self {
78 Self::Api {
79 status: 400,
80 headers: headers.map(Box::new),
81 body: body.map(Box::new),
82 message: message.into(),
83 }
84 }
85
86 pub fn authentication(
88 headers: Option<HeaderMap>,
89 body: Option<Value>,
90 message: impl Into<String>,
91 ) -> Self {
92 Self::Api {
93 status: 401,
94 headers: headers.map(Box::new),
95 body: body.map(Box::new),
96 message: message.into(),
97 }
98 }
99
100 pub fn permission_denied(
102 headers: Option<HeaderMap>,
103 body: Option<Value>,
104 message: impl Into<String>,
105 ) -> Self {
106 Self::Api {
107 status: 403,
108 headers: headers.map(Box::new),
109 body: body.map(Box::new),
110 message: message.into(),
111 }
112 }
113
114 pub fn not_found(
116 headers: Option<HeaderMap>,
117 body: Option<Value>,
118 message: impl Into<String>,
119 ) -> Self {
120 Self::Api {
121 status: 404,
122 headers: headers.map(Box::new),
123 body: body.map(Box::new),
124 message: message.into(),
125 }
126 }
127
128 pub fn conflict(
130 headers: Option<HeaderMap>,
131 body: Option<Value>,
132 message: impl Into<String>,
133 ) -> Self {
134 Self::Api {
135 status: 409,
136 headers: headers.map(Box::new),
137 body: body.map(Box::new),
138 message: message.into(),
139 }
140 }
141
142 pub fn unprocessable_entity(
144 headers: Option<HeaderMap>,
145 body: Option<Value>,
146 message: impl Into<String>,
147 ) -> Self {
148 Self::Api {
149 status: 422,
150 headers: headers.map(Box::new),
151 body: body.map(Box::new),
152 message: message.into(),
153 }
154 }
155
156 pub fn rate_limit(
158 headers: Option<HeaderMap>,
159 body: Option<Value>,
160 message: impl Into<String>,
161 ) -> Self {
162 Self::Api {
163 status: 429,
164 headers: headers.map(Box::new),
165 body: body.map(Box::new),
166 message: message.into(),
167 }
168 }
169
170 pub fn internal_server(
172 status: u16,
173 headers: Option<HeaderMap>,
174 body: Option<Value>,
175 message: impl Into<String>,
176 ) -> Self {
177 debug_assert!(status >= 500, "internal_server expects status >= 500");
178 Self::Api {
179 status,
180 headers: headers.map(Box::new),
181 body: body.map(Box::new),
182 message: message.into(),
183 }
184 }
185
186 pub fn from_response(status: u16, headers: Option<HeaderMap>, body: Option<Value>) -> Self {
193 let message =
194 body.as_ref().and_then(|b| b.get("message")).and_then(|m| m.as_str()).map_or_else(
195 || {
196 body.as_ref().map_or_else(
197 || format!("{status} status code (no body)"),
198 std::string::ToString::to_string,
199 )
200 },
201 String::from,
202 );
203
204 match status {
205 400 => Self::bad_request(headers, body, message),
206 401 => Self::authentication(headers, body, message),
207 403 => Self::permission_denied(headers, body, message),
208 404 => Self::not_found(headers, body, message),
209 409 => Self::conflict(headers, body, message),
210 422 => Self::unprocessable_entity(headers, body, message),
211 429 => Self::rate_limit(headers, body, message),
212 s if s >= 500 => Self::internal_server(status, headers, body, message),
213 _ => Self::Api {
214 status,
215 headers: headers.map(Box::new),
216 body: body.map(Box::new),
217 message,
218 },
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use serde_json::json;
226
227 use super::*;
228
229 #[test]
232 fn display_api_error() {
233 let err = OpencodeError::Api {
234 status: 500,
235 headers: None,
236 body: None,
237 message: "Internal Server Error".into(),
238 };
239 assert_eq!(err.to_string(), "500 Internal Server Error");
240 }
241
242 #[test]
243 fn display_connection_error() {
244 let err = OpencodeError::Connection { message: "DNS lookup failed".into(), source: None };
245 assert_eq!(err.to_string(), "Connection error: DNS lookup failed");
246 }
247
248 #[test]
249 fn display_timeout() {
250 assert_eq!(OpencodeError::Timeout.to_string(), "Request timed out.");
251 }
252
253 #[test]
254 fn display_user_abort() {
255 assert_eq!(OpencodeError::UserAbort.to_string(), "Request was aborted.");
256 }
257
258 #[test]
259 fn display_serialization() {
260 let raw = serde_json::from_str::<Value>("not json").unwrap_err();
261 let err = OpencodeError::Serialization(raw);
262 assert!(err.to_string().starts_with("Serialization error:"));
263 }
264
265 #[test]
266 fn display_http() {
267 let inner: Box<dyn std::error::Error + Send + Sync> = "transport broke".into();
268 let err = OpencodeError::Http(inner);
269 assert_eq!(err.to_string(), "HTTP error: transport broke");
270 }
271
272 #[test]
275 fn status_returns_code_for_api() {
276 let err = OpencodeError::bad_request(None, None, "bad");
277 assert_eq!(err.status(), Some(400));
278 }
279
280 #[test]
281 fn status_returns_none_for_non_api() {
282 assert_eq!(OpencodeError::Timeout.status(), None);
283 assert_eq!(OpencodeError::UserAbort.status(), None);
284 let conn = OpencodeError::Connection { message: "x".into(), source: None };
285 assert_eq!(conn.status(), None);
286 }
287
288 #[test]
291 fn retryable_status_codes() {
292 for code in [408, 409, 429, 500, 502, 503, 504] {
294 let err =
295 OpencodeError::Api { status: code, headers: None, body: None, message: "x".into() };
296 assert!(err.is_retryable(), "status {code} should be retryable");
297 }
298 }
299
300 #[test]
301 fn non_retryable_status_codes() {
302 for code in [400, 401, 403, 404, 422] {
303 let err =
304 OpencodeError::Api { status: code, headers: None, body: None, message: "x".into() };
305 assert!(!err.is_retryable(), "status {code} should NOT be retryable");
306 }
307 }
308
309 #[test]
310 fn connection_and_timeout_are_retryable() {
311 let conn = OpencodeError::Connection { message: "fail".into(), source: None };
312 assert!(conn.is_retryable());
313 assert!(OpencodeError::Timeout.is_retryable());
314 }
315
316 #[test]
317 fn user_abort_not_retryable() {
318 assert!(!OpencodeError::UserAbort.is_retryable());
319 }
320
321 #[test]
322 fn http_and_serialization_not_retryable() {
323 let inner: Box<dyn std::error::Error + Send + Sync> = "oops".into();
324 assert!(!OpencodeError::Http(inner).is_retryable());
325
326 let raw = serde_json::from_str::<Value>("bad").unwrap_err();
327 assert!(!OpencodeError::Serialization(raw).is_retryable());
328 }
329
330 #[test]
333 fn is_timeout_only_for_timeout() {
334 assert!(OpencodeError::Timeout.is_timeout());
335 assert!(!OpencodeError::UserAbort.is_timeout());
336 let api = OpencodeError::bad_request(None, None, "x");
337 assert!(!api.is_timeout());
338 }
339
340 #[test]
343 fn convenience_constructors_set_correct_status() {
344 assert_eq!(OpencodeError::bad_request(None, None, "x").status(), Some(400));
345 assert_eq!(OpencodeError::authentication(None, None, "x").status(), Some(401));
346 assert_eq!(OpencodeError::permission_denied(None, None, "x").status(), Some(403));
347 assert_eq!(OpencodeError::not_found(None, None, "x").status(), Some(404));
348 assert_eq!(OpencodeError::conflict(None, None, "x").status(), Some(409));
349 assert_eq!(OpencodeError::unprocessable_entity(None, None, "x").status(), Some(422));
350 assert_eq!(OpencodeError::rate_limit(None, None, "x").status(), Some(429));
351 assert_eq!(OpencodeError::internal_server(500, None, None, "x").status(), Some(500));
352 assert_eq!(OpencodeError::internal_server(503, None, None, "x").status(), Some(503));
353 }
354
355 #[test]
358 fn from_response_maps_known_status_codes() {
359 let cases: &[(u16, &str)] = &[
360 (400, "400"),
361 (401, "401"),
362 (403, "403"),
363 (404, "404"),
364 (409, "409"),
365 (422, "422"),
366 (429, "429"),
367 (500, "500"),
368 (502, "502"),
369 ];
370 for &(code, prefix) in cases {
371 let err = OpencodeError::from_response(code, None, None);
372 assert_eq!(err.status(), Some(code), "from_response({code}) status mismatch");
373 assert!(
374 err.to_string().starts_with(prefix),
375 "from_response({code}) display should start with {prefix}, got: {}",
376 err.to_string()
377 );
378 }
379 }
380
381 #[test]
382 fn from_response_extracts_message_from_body() {
383 let body = json!({"message": "quota exceeded"});
384 let err = OpencodeError::from_response(429, None, Some(body));
385 assert_eq!(err.to_string(), "429 quota exceeded");
386 }
387
388 #[test]
389 fn from_response_falls_back_to_json_body() {
390 let body = json!({"error": "oops"});
391 let err = OpencodeError::from_response(400, None, Some(body.clone()));
392 assert!(err.to_string().contains("oops"));
394 }
395
396 #[test]
397 fn from_response_unknown_status_creates_generic_api() {
398 let err = OpencodeError::from_response(418, None, None);
399 assert_eq!(err.status(), Some(418));
400 assert!(err.to_string().contains("418"));
401 }
402
403 #[test]
404 fn from_response_preserves_headers() {
405 let mut headers = HeaderMap::new();
406 headers.insert("x-request-id", "abc123".parse().unwrap());
407 let err = OpencodeError::from_response(500, Some(headers), None);
408 if let OpencodeError::Api { headers: Some(h), .. } = &err {
409 assert_eq!(h.get("x-request-id").unwrap(), "abc123");
410 } else {
411 panic!("expected Api variant with headers");
412 }
413 }
414}