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
88impl From<anyhow::Error> for Error {
89    fn from(value: anyhow::Error) -> Self {
90        Error::Other(value.to_string())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_llm_error_format() {
100        let err = Error::Llm {
101            provider: "openai".into(),
102            message: "rate limit hit".into(),
103        };
104        let msg = err.to_string();
105        assert!(msg.contains("openai"));
106        assert!(msg.contains("rate limit"));
107    }
108
109    #[test]
110    fn test_rate_limited_format() {
111        let err = Error::RateLimited {
112            provider: "deepseek".into(),
113            retry_after_ms: 5000,
114        };
115        let msg = err.to_string();
116        assert!(msg.contains("deepseek"));
117        assert!(msg.contains("5000"));
118    }
119
120    #[test]
121    fn test_tool_error_format() {
122        let err = Error::Tool {
123            name: "run_shell".into(),
124            message: "command not found".into(),
125        };
126        let msg = err.to_string();
127        assert!(msg.contains("run_shell"));
128        assert!(msg.contains("command not found"));
129    }
130
131    #[test]
132    fn test_bad_tool_args_format() {
133        let err = Error::BadToolArgs {
134            name: "read_file".into(),
135            message: "missing path".into(),
136        };
137        let msg = err.to_string();
138        assert!(msg.contains("read_file"));
139        assert!(msg.contains("missing path"));
140    }
141
142    #[test]
143    fn test_permission_denied_format() {
144        let err = Error::PermissionDenied {
145            name: "run_shell".into(),
146        };
147        let msg = err.to_string();
148        assert!(msg.contains("run_shell"));
149    }
150
151    #[test]
152    fn test_mcp_error_format() {
153        let err = Error::Mcp {
154            server: "filesystem".into(),
155            message: "connection refused".into(),
156        };
157        let msg = err.to_string();
158        assert!(msg.contains("filesystem"));
159        assert!(msg.contains("connection refused"));
160    }
161
162    #[test]
163    fn test_config_error_format() {
164        let err = Error::Config {
165            message: "missing RECURSIVE_API_KEY".into(),
166        };
167        let msg = err.to_string();
168        assert!(msg.contains("missing RECURSIVE_API_KEY"));
169    }
170
171    #[test]
172    fn test_timeout_format() {
173        let err = Error::Timeout { duration_ms: 30000 };
174        let msg = err.to_string();
175        assert!(msg.contains("30000"));
176    }
177
178    #[test]
179    fn test_is_retryable() {
180        assert!(Error::RateLimited {
181            provider: "x".into(),
182            retry_after_ms: 1000
183        }
184        .is_retryable());
185        assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
186        assert!(!Error::Tool {
187            name: "x".into(),
188            message: "fail".into()
189        }
190        .is_retryable());
191        assert!(!Error::Config {
192            message: "bad".into()
193        }
194        .is_retryable());
195    }
196
197    #[test]
198    fn test_is_transient() {
199        assert!(Error::RateLimited {
200            provider: "x".into(),
201            retry_after_ms: 1000
202        }
203        .is_transient());
204        assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
205        // reqwest::Error is transient by definition (network issues).
206        // We verify the variant match in the is_transient implementation itself.
207        assert!(!Error::Tool {
208            name: "x".into(),
209            message: "fail".into()
210        }
211        .is_transient());
212        assert!(!Error::Config {
213            message: "bad".into()
214        }
215        .is_transient());
216    }
217
218    #[test]
219    fn test_from_io_error() {
220        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
221        let err: Error = io_err.into();
222        assert!(matches!(err, Error::Io(_)));
223        assert!(err.to_string().contains("file not found"));
224    }
225
226    #[test]
227    fn test_from_anyhow_error() {
228        let anyhow_err = anyhow::anyhow!("something went wrong");
229        let err: Error = anyhow_err.into();
230        assert!(matches!(err, Error::Other(_)));
231        assert!(err.to_string().contains("something went wrong"));
232    }
233
234    #[test]
235    fn test_unknown_tool_format() {
236        let err = Error::UnknownTool("nonexistent".into());
237        let msg = err.to_string();
238        assert!(msg.contains("nonexistent"));
239    }
240
241    #[test]
242    fn test_provider_truncated_format() {
243        let err = Error::ProviderTruncated("length".into());
244        let msg = err.to_string();
245        assert!(msg.contains("length"));
246    }
247}