Skip to main content

erio_core/
error.rs

1//! Error types for the Erio agent runtime.
2
3use std::time::Duration;
4use thiserror::Error;
5
6fn llm_error_display(message: &str, status: &Option<u16>) -> String {
7    match status {
8        Some(code) => format!("LLM error ({code}): {message}"),
9        None => format!("LLM error: {message}"),
10    }
11}
12
13fn execution_failed_display(message: &str, exit_code: &Option<i32>) -> String {
14    match exit_code {
15        Some(code) => format!("Tool execution failed: {message} (exit code: {code})"),
16        None => format!("Tool execution failed: {message}"),
17    }
18}
19
20/// Tool-specific errors.
21#[derive(Debug, Error)]
22pub enum ToolError {
23    #[error("Tool timeout after {}s", .0.as_secs())]
24    Timeout(Duration),
25
26    #[error("Invalid parameters: {0}")]
27    InvalidParams(String),
28
29    #[error("Tool not found: {0}")]
30    NotFound(String),
31
32    #[error("{}", execution_failed_display(.message, .exit_code))]
33    ExecutionFailed {
34        message: String,
35        exit_code: Option<i32>,
36    },
37
38    #[error("Tool execution cancelled")]
39    Cancelled,
40}
41
42impl ToolError {
43    /// Returns `true` if the error is potentially transient and the operation could be retried.
44    pub fn is_retryable(&self) -> bool {
45        matches!(self, Self::Timeout(_))
46    }
47}
48
49/// Core errors for the Erio runtime.
50#[derive(Debug, Error)]
51pub enum CoreError {
52    #[error("{}", llm_error_display(.message, .status))]
53    Llm {
54        message: String,
55        status: Option<u16>,
56    },
57
58    #[error("Tool error: {0}")]
59    Tool(#[from] ToolError),
60
61    #[error("Embedding error: {message}")]
62    Embedding { message: String },
63
64    #[error("Context store error: {message}")]
65    ContextStore { message: String },
66
67    #[error("Event bus error: {message}")]
68    EventBus { message: String },
69}
70
71impl CoreError {
72    /// Returns `true` if the error is potentially transient and the operation could be retried.
73    pub fn is_retryable(&self) -> bool {
74        match self {
75            Self::Llm { status, .. } => {
76                // 429 (rate limit), 5xx (server errors), or network errors (no status)
77                matches!(status, None | Some(429 | 500..))
78            }
79            Self::Tool(err) => err.is_retryable(),
80            Self::Embedding { .. } | Self::ContextStore { .. } | Self::EventBus { .. } => false,
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::time::Duration;
89
90    // === CoreError Display Tests ===
91
92    #[test]
93    fn llm_error_displays_message_with_status() {
94        let err = CoreError::Llm {
95            message: "rate limited".into(),
96            status: Some(429),
97        };
98        assert_eq!(err.to_string(), "LLM error (429): rate limited");
99    }
100
101    #[test]
102    fn llm_error_displays_message_without_status() {
103        let err = CoreError::Llm {
104            message: "connection failed".into(),
105            status: None,
106        };
107        assert_eq!(err.to_string(), "LLM error: connection failed");
108    }
109
110    // === ToolError Display Tests ===
111
112    #[test]
113    fn tool_error_timeout_displays_duration() {
114        let err = ToolError::Timeout(Duration::from_secs(30));
115        assert_eq!(err.to_string(), "Tool timeout after 30s");
116    }
117
118    #[test]
119    fn tool_error_invalid_params_displays_message() {
120        let err = ToolError::InvalidParams("missing 'command' field".into());
121        assert_eq!(
122            err.to_string(),
123            "Invalid parameters: missing 'command' field"
124        );
125    }
126
127    #[test]
128    fn tool_error_not_found_displays_name() {
129        let err = ToolError::NotFound("calculator".into());
130        assert_eq!(err.to_string(), "Tool not found: calculator");
131    }
132
133    #[test]
134    fn tool_error_execution_failed_displays_details() {
135        let err = ToolError::ExecutionFailed {
136            message: "command failed".into(),
137            exit_code: Some(1),
138        };
139        assert!(err.to_string().contains("command failed"));
140        assert!(err.to_string().contains("exit code: 1"));
141    }
142
143    // === Retryable Tests ===
144
145    #[test]
146    fn tool_error_timeout_is_retryable() {
147        let err = ToolError::Timeout(Duration::from_secs(30));
148        assert!(err.is_retryable());
149    }
150
151    #[test]
152    fn tool_error_invalid_params_is_not_retryable() {
153        let err = ToolError::InvalidParams("bad json".into());
154        assert!(!err.is_retryable());
155    }
156
157    #[test]
158    fn tool_error_not_found_is_not_retryable() {
159        let err = ToolError::NotFound("unknown".into());
160        assert!(!err.is_retryable());
161    }
162
163    #[test]
164    fn tool_error_cancelled_is_not_retryable() {
165        let err = ToolError::Cancelled;
166        assert!(!err.is_retryable());
167    }
168
169    #[test]
170    fn core_error_llm_429_is_retryable() {
171        let err = CoreError::Llm {
172            message: "rate limited".into(),
173            status: Some(429),
174        };
175        assert!(err.is_retryable());
176    }
177
178    #[test]
179    fn core_error_llm_500_is_retryable() {
180        let err = CoreError::Llm {
181            message: "server error".into(),
182            status: Some(500),
183        };
184        assert!(err.is_retryable());
185    }
186
187    #[test]
188    fn core_error_llm_400_is_not_retryable() {
189        let err = CoreError::Llm {
190            message: "bad request".into(),
191            status: Some(400),
192        };
193        assert!(!err.is_retryable());
194    }
195
196    #[test]
197    fn core_error_llm_no_status_is_retryable() {
198        // Network errors without HTTP status should be retryable
199        let err = CoreError::Llm {
200            message: "connection reset".into(),
201            status: None,
202        };
203        assert!(err.is_retryable());
204    }
205
206    // === From Conversions ===
207
208    #[test]
209    fn core_error_from_tool_error() {
210        let tool_err = ToolError::NotFound("test".into());
211        let core_err: CoreError = tool_err.into();
212        assert!(matches!(core_err, CoreError::Tool(_)));
213    }
214
215    // === Embedding Error Tests ===
216
217    #[test]
218    fn core_error_embedding_displays_message() {
219        let err = CoreError::Embedding {
220            message: "model load failed".into(),
221        };
222        assert_eq!(err.to_string(), "Embedding error: model load failed");
223    }
224
225    #[test]
226    fn core_error_embedding_is_not_retryable() {
227        let err = CoreError::Embedding {
228            message: "dimension mismatch".into(),
229        };
230        assert!(!err.is_retryable());
231    }
232
233    // === Event Bus Error Tests ===
234
235    #[test]
236    fn core_error_event_bus_displays_message() {
237        let err = CoreError::EventBus {
238            message: "source failed".into(),
239        };
240        assert_eq!(err.to_string(), "Event bus error: source failed");
241    }
242
243    #[test]
244    fn core_error_event_bus_is_not_retryable() {
245        let err = CoreError::EventBus {
246            message: "channel closed".into(),
247        };
248        assert!(!err.is_retryable());
249    }
250
251    // === Context Store Error Tests ===
252
253    #[test]
254    fn core_error_context_store_displays_message() {
255        let err = CoreError::ContextStore {
256            message: "table not found".into(),
257        };
258        assert_eq!(err.to_string(), "Context store error: table not found");
259    }
260
261    #[test]
262    fn core_error_context_store_is_not_retryable() {
263        let err = CoreError::ContextStore {
264            message: "storage failure".into(),
265        };
266        assert!(!err.is_retryable());
267    }
268}