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
97/// Convenience alias so callers can write `Result<T>` instead of
98/// `std::result::Result<T, claude_cli_sdk::Error>`.
99pub type Result<T> = std::result::Result<T, Error>;
100
101// ── Helpers ──────────────────────────────────────────────────────────────────
102
103impl Error {
104    /// Returns `true` if this error indicates the process exited cleanly but
105    /// with a failure code (i.e., not a transport or protocol fault).
106    #[inline]
107    #[must_use]
108    pub fn is_process_exit(&self) -> bool {
109        matches!(self, Self::ProcessExited { .. })
110    }
111
112    /// Returns `true` if this error is transient and the caller might
113    /// reasonably retry (e.g., I/O errors, timeouts).
114    #[inline]
115    #[must_use]
116    pub fn is_retriable(&self) -> bool {
117        matches!(self, Self::Io(_) | Self::Timeout(_) | Self::Transport(_))
118    }
119}
120
121// ── Tests ─────────────────────────────────────────────────────────────────────
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    // Helper: convert an Error to its Display string.
128    fn display(e: &Error) -> String {
129        e.to_string()
130    }
131
132    #[test]
133    fn cli_not_found_display() {
134        let e = Error::CliNotFound;
135        assert!(
136            display(&e).contains("npm install -g @anthropic-ai/claude-code"),
137            "message should include install hint, got: {e}"
138        );
139    }
140
141    #[test]
142    fn version_mismatch_display() {
143        let e = Error::VersionMismatch {
144            found: "1.0.0".to_owned(),
145            required: "2.0.0".to_owned(),
146        };
147        let s = display(&e);
148        assert!(s.contains("1.0.0"), "should contain found version");
149        assert!(s.contains("2.0.0"), "should contain required version");
150    }
151
152    #[test]
153    fn spawn_failed_display() {
154        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
155        let e = Error::SpawnFailed(inner);
156        assert!(display(&e).contains("Failed to spawn Claude process"));
157    }
158
159    #[test]
160    fn process_exited_display_with_code() {
161        let e = Error::ProcessExited {
162            code: Some(1),
163            stderr: "fatal error".to_owned(),
164        };
165        let s = display(&e);
166        assert!(s.contains("1"), "should contain exit code");
167        assert!(s.contains("fatal error"), "should contain stderr");
168    }
169
170    #[test]
171    fn process_exited_display_signal_kill() {
172        let e = Error::ProcessExited {
173            code: None,
174            stderr: String::new(),
175        };
176        // code: None should render as "None"
177        assert!(display(&e).contains("None"));
178    }
179
180    #[test]
181    fn parse_error_display() {
182        let e = Error::ParseError {
183            message: "unexpected token".to_owned(),
184            line: "{bad json}".to_owned(),
185        };
186        let s = display(&e);
187        assert!(s.contains("unexpected token"));
188        assert!(s.contains("{bad json}"));
189    }
190
191    #[test]
192    fn io_error_display() {
193        let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
194        let e = Error::from(inner);
195        assert!(display(&e).contains("I/O error"));
196    }
197
198    #[test]
199    fn json_error_display() {
200        let inner: serde_json::Error =
201            serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
202        let e = Error::from(inner);
203        assert!(display(&e).contains("JSON error"));
204    }
205
206    #[test]
207    fn not_connected_display() {
208        let e = Error::NotConnected;
209        assert!(display(&e).contains("connect()"));
210    }
211
212    #[test]
213    fn transport_display() {
214        let e = Error::Transport("connection refused".to_owned());
215        assert!(display(&e).contains("connection refused"));
216    }
217
218    #[test]
219    fn config_display() {
220        let e = Error::Config("max_turns must be > 0".to_owned());
221        assert!(display(&e).contains("max_turns must be > 0"));
222    }
223
224    #[test]
225    fn control_protocol_display() {
226        let e = Error::ControlProtocol("unexpected init sequence".to_owned());
227        assert!(display(&e).contains("unexpected init sequence"));
228    }
229
230    #[test]
231    fn image_validation_display() {
232        let e = Error::ImageValidation("unsupported MIME type: image/bmp".to_owned());
233        assert!(display(&e).contains("image/bmp"));
234    }
235
236    #[test]
237    fn timeout_display() {
238        let e = Error::Timeout("connect timed out after 30s".to_owned());
239        assert!(display(&e).contains("30s"));
240    }
241
242    // ── Helper methods ────────────────────────────────────────────────────────
243
244    #[test]
245    fn is_process_exit_true_for_process_exited() {
246        let e = Error::ProcessExited {
247            code: Some(1),
248            stderr: String::new(),
249        };
250        assert!(e.is_process_exit());
251    }
252
253    #[test]
254    fn is_process_exit_false_for_other_variants() {
255        assert!(!Error::NotConnected.is_process_exit());
256        assert!(!Error::CliNotFound.is_process_exit());
257    }
258
259    #[test]
260    fn is_retriable_for_io_timeout_transport() {
261        let io_err = Error::Io(std::io::Error::new(
262            std::io::ErrorKind::ConnectionReset,
263            "reset",
264        ));
265        assert!(io_err.is_retriable());
266        assert!(Error::Timeout("deadline".to_owned()).is_retriable());
267        assert!(Error::Transport("broken".to_owned()).is_retriable());
268    }
269
270    #[test]
271    fn is_retriable_false_for_not_connected() {
272        assert!(!Error::NotConnected.is_retriable());
273    }
274
275    #[test]
276    fn result_alias_compiles() {
277        fn ok_fn() -> Result<u32> {
278            Ok(42)
279        }
280        assert_eq!(ok_fn().unwrap(), 42);
281    }
282}