Skip to main content

recursive/
error.rs

1//! Crate-wide error and Result.
2//!
3//! Structured error types that library consumers can match on.
4//! Every distinct failure mode has its own variant.
5
6use thiserror::Error;
7
8pub type Result<T, E = Error> = std::result::Result<T, E>;
9
10#[derive(Debug, Error)]
11pub enum Error {
12    /// LLM provider returned an error (HTTP, parse, etc.)
13    #[error("LLM error ({provider}): {message}")]
14    Llm { provider: String, message: String },
15
16    /// LLM rate limited — caller should retry after `retry_after_ms`
17    #[error("LLM rate limited ({provider}): retry after {retry_after_ms}ms")]
18    RateLimited {
19        provider: String,
20        retry_after_ms: u64,
21    },
22
23    /// Tool execution failure (spawn, timeout, I/O)
24    #[error("tool error ({name}): {message}")]
25    Tool { name: String, message: String },
26
27    /// Invalid arguments passed to a tool
28    #[error("bad tool arguments ({name}): {message}")]
29    BadToolArgs { name: String, message: String },
30
31    /// Tool not found in registry
32    #[error("tool `{0}` not found")]
33    UnknownTool(String),
34
35    /// Permission denied for a tool call
36    #[error("permission denied: tool {name}")]
37    PermissionDenied { name: String },
38
39    /// LLM response truncated by provider
40    #[error("llm response truncated by provider (finish_reason = {0:?})")]
41    ProviderTruncated(String),
42
43    /// MCP protocol/transport error
44    #[error("MCP error ({server}): {message}")]
45    Mcp { server: String, message: String },
46
47    /// Configuration error (missing env var, invalid value)
48    #[error("config error: {message}")]
49    Config { message: String },
50
51    /// I/O error
52    #[error("IO error: {0}")]
53    Io(#[from] std::io::Error),
54
55    /// HTTP client error
56    #[error("HTTP error: {0}")]
57    Http(#[from] reqwest::Error),
58
59    /// JSON serialization/deserialization error
60    #[error("JSON error: {0}")]
61    Json(#[from] serde_json::Error),
62
63    /// Timeout after a specified duration
64    #[error("timeout after {duration_ms}ms")]
65    Timeout { duration_ms: u64 },
66
67    /// Catch-all for errors that don't fit elsewhere
68    #[error("{0}")]
69    Other(String),
70}
71
72impl Error {
73    /// Returns `true` if the error is safe to retry (rate limits, timeouts).
74    pub fn is_retryable(&self) -> bool {
75        matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
76    }
77
78    /// Returns `true` if the error is transient (network issues, timeouts).
79    /// Transient errors may resolve without changing the request.
80    pub fn is_transient(&self) -> bool {
81        matches!(
82            self,
83            Error::RateLimited { .. } | Error::Timeout { .. } | Error::Http(_) | Error::Io(_)
84        )
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_llm_error_format() {
94        let err = Error::Llm {
95            provider: "openai".into(),
96            message: "rate limit hit".into(),
97        };
98        let msg = err.to_string();
99        assert!(msg.contains("openai"));
100        assert!(msg.contains("rate limit"));
101    }
102
103    #[test]
104    fn test_rate_limited_format() {
105        let err = Error::RateLimited {
106            provider: "deepseek".into(),
107            retry_after_ms: 5000,
108        };
109        let msg = err.to_string();
110        assert!(msg.contains("deepseek"));
111        assert!(msg.contains("5000"));
112    }
113
114    #[test]
115    fn test_tool_error_format() {
116        let err = Error::Tool {
117            name: "run_shell".into(),
118            message: "command not found".into(),
119        };
120        let msg = err.to_string();
121        assert!(msg.contains("run_shell"));
122        assert!(msg.contains("command not found"));
123    }
124
125    #[test]
126    fn test_bad_tool_args_format() {
127        let err = Error::BadToolArgs {
128            name: "read_file".into(),
129            message: "missing path".into(),
130        };
131        let msg = err.to_string();
132        assert!(msg.contains("read_file"));
133        assert!(msg.contains("missing path"));
134    }
135
136    #[test]
137    fn test_permission_denied_format() {
138        let err = Error::PermissionDenied {
139            name: "run_shell".into(),
140        };
141        let msg = err.to_string();
142        assert!(msg.contains("run_shell"));
143    }
144
145    #[test]
146    fn test_mcp_error_format() {
147        let err = Error::Mcp {
148            server: "filesystem".into(),
149            message: "connection refused".into(),
150        };
151        let msg = err.to_string();
152        assert!(msg.contains("filesystem"));
153        assert!(msg.contains("connection refused"));
154    }
155
156    #[test]
157    fn test_config_error_format() {
158        let err = Error::Config {
159            message: "missing RECURSIVE_API_KEY".into(),
160        };
161        let msg = err.to_string();
162        assert!(msg.contains("missing RECURSIVE_API_KEY"));
163    }
164
165    #[test]
166    fn test_timeout_format() {
167        let err = Error::Timeout { duration_ms: 30000 };
168        let msg = err.to_string();
169        assert!(msg.contains("30000"));
170    }
171
172    #[test]
173    fn test_is_retryable() {
174        assert!(Error::RateLimited {
175            provider: "x".into(),
176            retry_after_ms: 1000
177        }
178        .is_retryable());
179        assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
180        assert!(!Error::Tool {
181            name: "x".into(),
182            message: "fail".into()
183        }
184        .is_retryable());
185        assert!(!Error::Config {
186            message: "bad".into()
187        }
188        .is_retryable());
189    }
190
191    #[test]
192    fn test_is_transient() {
193        assert!(Error::RateLimited {
194            provider: "x".into(),
195            retry_after_ms: 1000
196        }
197        .is_transient());
198        assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
199        // reqwest::Error is transient by definition (network issues).
200        // We verify the variant match in the is_transient implementation itself.
201        assert!(!Error::Tool {
202            name: "x".into(),
203            message: "fail".into()
204        }
205        .is_transient());
206        assert!(!Error::Config {
207            message: "bad".into()
208        }
209        .is_transient());
210    }
211
212    #[test]
213    fn test_from_io_error() {
214        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
215        let err: Error = io_err.into();
216        assert!(matches!(err, Error::Io(_)));
217        assert!(err.to_string().contains("file not found"));
218    }
219
220    #[test]
221    fn test_unknown_tool_format() {
222        let err = Error::UnknownTool("nonexistent".into());
223        let msg = err.to_string();
224        assert!(msg.contains("nonexistent"));
225    }
226
227    #[test]
228    fn test_provider_truncated_format() {
229        let err = Error::ProviderTruncated("length".into());
230        let msg = err.to_string();
231        assert!(msg.contains("length"));
232    }
233}