Skip to main content

claude_cli_sdk/
errors.rs

1//! Error types for the claude-cli-sdk.
2//!
3//! All fallible operations in this crate return [`Result<T>`], which is an alias
4//! for `std::result::Result<T, Error>`.
5//!
6//! # Example
7//!
8//! ```rust
9//! use claude_cli_sdk::{Error, Result};
10//!
11//! fn might_fail() -> Result<()> {
12//!     Err(Error::NotConnected)
13//! }
14//! ```
15
16/// All errors that can be produced by the claude-code SDK.
17///
18/// Variants are non-exhaustive in the sense that future SDK versions may add
19/// new variants — callers should include a wildcard arm when matching.
20#[derive(Debug, thiserror::Error)]
21#[non_exhaustive]
22pub enum Error {
23    /// The Claude Code CLI binary was not found on `PATH`.
24    ///
25    /// Install it with: `npm install -g @anthropic-ai/claude-code`
26    #[error("Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code")]
27    CliNotFound,
28
29    /// The discovered CLI version is below the minimum required by this SDK.
30    #[error("CLI version {found} below minimum {required}")]
31    VersionMismatch {
32        /// The version that was discovered on the system.
33        found: String,
34        /// The minimum version required by this SDK.
35        required: String,
36    },
37
38    /// The OS failed to spawn the Claude child process.
39    #[error("Failed to spawn Claude process: {0}")]
40    SpawnFailed(#[source] std::io::Error),
41
42    /// The Claude process exited with a non-zero (or missing) status code.
43    ///
44    /// `code` is `None` when the process was killed by a signal.
45    #[error("Claude process exited with code {code:?}: {stderr}")]
46    ProcessExited {
47        /// The exit code, or `None` if the process was killed by a signal.
48        code: Option<i32>,
49        /// Captured stderr output from the process.
50        stderr: String,
51    },
52
53    /// A line from the Claude process could not be parsed as valid JSON.
54    #[error("Failed to parse JSON: {message} (line: {line})")]
55    ParseError {
56        /// Human-readable description of the parse failure.
57        message: String,
58        /// The raw line that could not be parsed (truncated to 200 chars).
59        line: String,
60    },
61
62    /// Transparent wrapper around [`std::io::Error`].
63    #[error("I/O error: {0}")]
64    Io(#[from] std::io::Error),
65
66    /// Transparent wrapper around [`serde_json::Error`].
67    #[error("JSON error: {0}")]
68    Json(#[from] serde_json::Error),
69
70    /// A method that requires an active connection was called before [`connect()`](crate::Client).
71    #[error("Client not connected. Call connect() first.")]
72    NotConnected,
73
74    /// An error in the underlying stdio/socket transport layer.
75    #[error("Transport error: {0}")]
76    Transport(String),
77
78    /// A configuration value is absent or out of range.
79    #[error("Invalid configuration: {0}")]
80    Config(String),
81
82    /// The structured control protocol exchanged with the CLI returned an
83    /// unexpected message or sequence.
84    #[error("Control protocol error: {0}")]
85    ControlProtocol(String),
86
87    /// An image payload failed validation (unsupported MIME type or exceeds
88    /// the 15 MiB base64 size limit).
89    #[error("Image validation error: {0}")]
90    ImageValidation(String),
91
92    /// An async operation exceeded its deadline.
93    #[error("Operation timed out: {0}")]
94    Timeout(String),
95
96    /// The operation was cancelled via a [`CancellationToken`](tokio_util::sync::CancellationToken).
97    #[error("Operation cancelled")]
98    Cancelled,
99}
100
101/// Convenience alias so callers can write `Result<T>` instead of
102/// `std::result::Result<T, claude_cli_sdk::Error>`.
103pub type Result<T> = std::result::Result<T, Error>;
104
105// ── Helpers ──────────────────────────────────────────────────────────────────
106
107impl Error {
108    /// Returns `true` if this error indicates the process exited cleanly but
109    /// with a failure code (i.e., not a transport or protocol fault).
110    #[inline]
111    #[must_use]
112    pub fn is_process_exit(&self) -> bool {
113        matches!(self, Self::ProcessExited { .. })
114    }
115
116    /// Returns `true` if this error is transient and the caller might
117    /// reasonably retry (e.g., I/O errors, timeouts).
118    #[inline]
119    #[must_use]
120    pub fn is_retriable(&self) -> bool {
121        matches!(self, Self::Io(_) | Self::Timeout(_) | Self::Transport(_))
122    }
123
124    /// Returns `true` if this error indicates the operation was cancelled
125    /// via a [`CancellationToken`](tokio_util::sync::CancellationToken).
126    #[inline]
127    #[must_use]
128    pub fn is_cancelled(&self) -> bool {
129        matches!(self, Self::Cancelled)
130    }
131}
132
133// ── Tests ─────────────────────────────────────────────────────────────────────
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    // Helper: convert an Error to its Display string.
140    fn display(e: &Error) -> String {
141        e.to_string()
142    }
143
144    #[test]
145    fn cli_not_found_display() {
146        let e = Error::CliNotFound;
147        assert!(
148            display(&e).contains("npm install -g @anthropic-ai/claude-code"),
149            "message should include install hint, got: {e}"
150        );
151    }
152
153    #[test]
154    fn version_mismatch_display() {
155        let e = Error::VersionMismatch {
156            found: "1.0.0".to_owned(),
157            required: "2.0.0".to_owned(),
158        };
159        let s = display(&e);
160        assert!(s.contains("1.0.0"), "should contain found version");
161        assert!(s.contains("2.0.0"), "should contain required version");
162    }
163
164    #[test]
165    fn spawn_failed_display() {
166        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
167        let e = Error::SpawnFailed(inner);
168        assert!(display(&e).contains("Failed to spawn Claude process"));
169    }
170
171    #[test]
172    fn process_exited_display_with_code() {
173        let e = Error::ProcessExited {
174            code: Some(1),
175            stderr: "fatal error".to_owned(),
176        };
177        let s = display(&e);
178        assert!(s.contains("1"), "should contain exit code");
179        assert!(s.contains("fatal error"), "should contain stderr");
180    }
181
182    #[test]
183    fn process_exited_display_signal_kill() {
184        let e = Error::ProcessExited {
185            code: None,
186            stderr: String::new(),
187        };
188        // code: None should render as "None"
189        assert!(display(&e).contains("None"));
190    }
191
192    #[test]
193    fn parse_error_display() {
194        let e = Error::ParseError {
195            message: "unexpected token".to_owned(),
196            line: "{bad json}".to_owned(),
197        };
198        let s = display(&e);
199        assert!(s.contains("unexpected token"));
200        assert!(s.contains("{bad json}"));
201    }
202
203    #[test]
204    fn io_error_display() {
205        let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
206        let e = Error::from(inner);
207        assert!(display(&e).contains("I/O error"));
208    }
209
210    #[test]
211    fn json_error_display() {
212        let inner: serde_json::Error =
213            serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
214        let e = Error::from(inner);
215        assert!(display(&e).contains("JSON error"));
216    }
217
218    #[test]
219    fn not_connected_display() {
220        let e = Error::NotConnected;
221        assert!(display(&e).contains("connect()"));
222    }
223
224    #[test]
225    fn transport_display() {
226        let e = Error::Transport("connection refused".to_owned());
227        assert!(display(&e).contains("connection refused"));
228    }
229
230    #[test]
231    fn config_display() {
232        let e = Error::Config("max_turns must be > 0".to_owned());
233        assert!(display(&e).contains("max_turns must be > 0"));
234    }
235
236    #[test]
237    fn control_protocol_display() {
238        let e = Error::ControlProtocol("unexpected init sequence".to_owned());
239        assert!(display(&e).contains("unexpected init sequence"));
240    }
241
242    #[test]
243    fn image_validation_display() {
244        let e = Error::ImageValidation("unsupported MIME type: image/bmp".to_owned());
245        assert!(display(&e).contains("image/bmp"));
246    }
247
248    #[test]
249    fn timeout_display() {
250        let e = Error::Timeout("connect timed out after 30s".to_owned());
251        assert!(display(&e).contains("30s"));
252    }
253
254    // ── Helper methods ────────────────────────────────────────────────────────
255
256    #[test]
257    fn is_process_exit_true_for_process_exited() {
258        let e = Error::ProcessExited {
259            code: Some(1),
260            stderr: String::new(),
261        };
262        assert!(e.is_process_exit());
263    }
264
265    #[test]
266    fn is_process_exit_false_for_other_variants() {
267        assert!(!Error::NotConnected.is_process_exit());
268        assert!(!Error::CliNotFound.is_process_exit());
269    }
270
271    #[test]
272    fn is_retriable_for_io_timeout_transport() {
273        let io_err = Error::Io(std::io::Error::new(
274            std::io::ErrorKind::ConnectionReset,
275            "reset",
276        ));
277        assert!(io_err.is_retriable());
278        assert!(Error::Timeout("deadline".to_owned()).is_retriable());
279        assert!(Error::Transport("broken".to_owned()).is_retriable());
280    }
281
282    #[test]
283    fn is_retriable_false_for_not_connected() {
284        assert!(!Error::NotConnected.is_retriable());
285    }
286
287    #[test]
288    fn cancelled_display() {
289        let e = Error::Cancelled;
290        assert!(display(&e).contains("cancelled"));
291    }
292
293    #[test]
294    fn is_cancelled_true_for_cancelled() {
295        assert!(Error::Cancelled.is_cancelled());
296    }
297
298    #[test]
299    fn is_cancelled_false_for_other_variants() {
300        assert!(!Error::NotConnected.is_cancelled());
301        assert!(!Error::CliNotFound.is_cancelled());
302        assert!(!Error::Timeout("x".into()).is_cancelled());
303    }
304
305    #[test]
306    fn cancelled_is_not_retriable() {
307        assert!(!Error::Cancelled.is_retriable());
308    }
309
310    #[test]
311    fn result_alias_compiles() {
312        fn ok_fn() -> Result<u32> {
313            Ok(42)
314        }
315        assert_eq!(ok_fn().unwrap(), 42);
316    }
317}