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