Skip to main content

opencode_sdk_rs/
error.rs

1use http::HeaderMap;
2use serde_json::Value;
3
4/// Primary error type for the `OpenCode` SDK.
5///
6/// Models the JS SDK's error hierarchy as a flat enum with variants for
7/// API errors (with status codes), connection errors, timeouts, user aborts,
8/// serialization errors, and generic HTTP transport errors.
9#[derive(Debug, thiserror::Error)]
10pub enum OpencodeError {
11    /// An API error returned by the server with an HTTP status code.
12    #[error("{status} {message}")]
13    Api { status: u16, headers: Option<Box<HeaderMap>>, body: Option<Box<Value>>, message: String },
14
15    /// A connection-level error (DNS, TCP, TLS, etc.).
16    #[error("Connection error: {message}")]
17    Connection {
18        message: String,
19        #[source]
20        source: Option<Box<dyn std::error::Error + Send + Sync>>,
21    },
22
23    /// The request timed out.
24    #[error("Request timed out.")]
25    Timeout,
26
27    /// The user aborted the request.
28    #[error("Request was aborted.")]
29    UserAbort,
30
31    /// Failed to serialize or deserialize JSON.
32    #[error("Serialization error: {0}")]
33    Serialization(#[from] serde_json::Error),
34
35    /// An opaque HTTP transport error.
36    #[error("HTTP error: {0}")]
37    Http(#[source] Box<dyn std::error::Error + Send + Sync>),
38}
39
40impl OpencodeError {
41    // ── Query helpers ──────────────────────────────────────────────
42
43    /// Returns the HTTP status code if this is an `Api` variant.
44    pub const fn status(&self) -> Option<u16> {
45        match self {
46            Self::Api { status, .. } => Some(*status),
47            _ => None,
48        }
49    }
50
51    /// Whether this error should be retried.
52    ///
53    /// Mirrors the JS SDK logic:
54    /// - Status 408, 409, 429, >= 500 → retryable
55    /// - Connection errors and timeouts → retryable
56    /// - Everything else → not retryable
57    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    /// Whether this error represents a timeout.
66    pub const fn is_timeout(&self) -> bool {
67        matches!(self, Self::Timeout)
68    }
69
70    // ── Convenience constructors for specific HTTP statuses ─────────
71
72    /// 400 Bad Request
73    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    /// 401 Authentication Error
87    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    /// 403 Permission Denied
101    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    /// 404 Not Found
115    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    /// 409 Conflict
129    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    /// 422 Unprocessable Entity
143    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    /// 429 Rate Limit
157    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    /// 5xx Internal Server Error
171    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    // ── Factory ────────────────────────────────────────────────────
187
188    /// Create an error from an HTTP response's status, headers, and body.
189    ///
190    /// Maps well-known status codes to their specific constructors; falls
191    /// through to a generic `Api` variant for other codes.
192    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    // ── Display ────────────────────────────────────────────────────
230
231    #[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    // ── status() ───────────────────────────────────────────────────
273
274    #[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    // ── is_retryable() ─────────────────────────────────────────────
289
290    #[test]
291    fn retryable_status_codes() {
292        // Retryable HTTP statuses
293        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    // ── is_timeout() ───────────────────────────────────────────────
331
332    #[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    // ── Convenience constructors ───────────────────────────────────
341
342    #[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    // ── from_response() ────────────────────────────────────────────
356
357    #[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        // No "message" key → falls back to JSON stringification
393        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}