Skip to main content

ironflow_core/
error.rs

1//! Error types for ironflow operations.
2//!
3//! This module defines two error enums:
4//!
5//! * [`OperationError`] - top-level error returned by both shell and agent operations.
6//! * [`AgentError`] - agent-specific error returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke).
7//!
8//! [`AgentError`] converts into [`OperationError`] via the [`From`] trait, so agent
9//! errors propagate naturally through the `?` operator.
10
11use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use thiserror::Error;
16
17/// Top-level error for any workflow operation (shell or agent).
18///
19/// Every public operation in ironflow returns `Result<T, OperationError>`.
20#[derive(Debug, Error)]
21pub enum OperationError {
22    /// A shell command exited with a non-zero status code.
23    #[error("shell exited with code {exit_code}: {stderr}")]
24    Shell {
25        /// Process exit code, or `-1` if the process could not be spawned.
26        exit_code: i32,
27        /// Captured stderr, truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
28        stderr: String,
29    },
30
31    /// An agent invocation failed.
32    ///
33    /// Wraps an [`AgentError`] with full detail about the failure.
34    #[error("agent error: {0}")]
35    Agent(#[from] AgentError),
36
37    /// An operation exceeded its configured timeout.
38    #[error("step '{step}' timed out after {limit:?}")]
39    Timeout {
40        /// Human-readable description of the timed-out step (usually the command string).
41        step: String,
42        /// The [`Duration`] that was exceeded.
43        limit: Duration,
44    },
45
46    /// An HTTP request failed at the transport layer or the response body
47    /// could not be read.
48    #[error("{}", match status {
49        Some(code) => format!("http error (status {code}): {message}"),
50        None => format!("http error: {message}"),
51    })]
52    Http {
53        /// HTTP status code, if a response was received.
54        status: Option<u16>,
55        /// Human-readable error description.
56        message: String,
57    },
58
59    /// Failed to deserialize a JSON response into the expected Rust type.
60    ///
61    /// Returned by [`AgentResult::json`](crate::operations::agent::AgentResult::json)
62    /// and [`HttpOutput::json`](crate::operations::http::HttpOutput::json).
63    #[error("failed to deserialize into {target_type}: {reason}")]
64    Deserialize {
65        /// The Rust type name that was expected.
66        target_type: String,
67        /// The underlying serde error message.
68        reason: String,
69    },
70}
71
72impl OperationError {
73    /// Build a [`Deserialize`](OperationError::Deserialize) error for type `T`.
74    pub fn deserialize<T>(error: impl fmt::Display) -> Self {
75        Self::Deserialize {
76            target_type: any::type_name::<T>().to_string(),
77            reason: error.to_string(),
78        }
79    }
80}
81
82/// Error specific to agent (AI provider) invocations.
83///
84/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
85/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
86#[derive(Debug, Error)]
87pub enum AgentError {
88    /// The agent process exited with a non-zero status code.
89    #[error("claude process exited with code {exit_code}: {stderr}")]
90    ProcessFailed {
91        /// Process exit code, or `-1` if spawning failed.
92        exit_code: i32,
93        /// Captured stderr.
94        stderr: String,
95    },
96
97    /// The agent output did not match the expected schema.
98    #[error("schema validation failed: expected {expected}, got {got}")]
99    SchemaValidation {
100        /// What was expected (e.g. `"structured_output field"`).
101        expected: String,
102        /// What was actually received.
103        got: String,
104    },
105
106    /// The prompt exceeds the model's context window.
107    ///
108    /// Returned before spawning the process when the estimated token count
109    /// exceeds the model's known limit.
110    ///
111    /// * `chars` - number of characters in the combined prompt (system + user).
112    /// * `estimated_tokens` - approximate token count (chars / 4).
113    /// * `model_limit` - the model's context window in tokens.
114    #[error(
115        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
116    )]
117    PromptTooLarge {
118        /// Number of characters in the prompt.
119        chars: usize,
120        /// Estimated token count (chars / 4 heuristic).
121        estimated_tokens: usize,
122        /// Model's context window in tokens.
123        model_limit: usize,
124    },
125
126    /// The agent did not complete within the configured timeout.
127    #[error("agent timed out after {limit:?}")]
128    Timeout {
129        /// The [`Duration`] that was exceeded.
130        limit: Duration,
131    },
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn shell_display_format() {
140        let err = OperationError::Shell {
141            exit_code: 127,
142            stderr: "command not found".to_string(),
143        };
144        assert_eq!(
145            err.to_string(),
146            "shell exited with code 127: command not found"
147        );
148    }
149
150    #[test]
151    fn agent_display_delegates_to_agent_error() {
152        let inner = AgentError::ProcessFailed {
153            exit_code: 1,
154            stderr: "boom".to_string(),
155        };
156        let err = OperationError::Agent(inner);
157        assert_eq!(
158            err.to_string(),
159            "agent error: claude process exited with code 1: boom"
160        );
161    }
162
163    #[test]
164    fn timeout_display_format() {
165        let err = OperationError::Timeout {
166            step: "build".to_string(),
167            limit: Duration::from_secs(30),
168        };
169        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
170    }
171
172    #[test]
173    fn agent_error_process_failed_display_zero_exit_code() {
174        let err = AgentError::ProcessFailed {
175            exit_code: 0,
176            stderr: "unexpected".to_string(),
177        };
178        assert_eq!(
179            err.to_string(),
180            "claude process exited with code 0: unexpected"
181        );
182    }
183
184    #[test]
185    fn agent_error_process_failed_display_negative_exit_code() {
186        let err = AgentError::ProcessFailed {
187            exit_code: -1,
188            stderr: "killed".to_string(),
189        };
190        assert!(err.to_string().contains("-1"));
191    }
192
193    #[test]
194    fn agent_error_schema_validation_display() {
195        let err = AgentError::SchemaValidation {
196            expected: "object".to_string(),
197            got: "string".to_string(),
198        };
199        assert_eq!(
200            err.to_string(),
201            "schema validation failed: expected object, got string"
202        );
203    }
204
205    #[test]
206    fn agent_error_timeout_display() {
207        let err = AgentError::Timeout {
208            limit: Duration::from_secs(300),
209        };
210        assert_eq!(err.to_string(), "agent timed out after 300s");
211    }
212
213    #[test]
214    fn from_agent_error_process_failed() {
215        let agent_err = AgentError::ProcessFailed {
216            exit_code: 42,
217            stderr: "fail".to_string(),
218        };
219        let op_err: OperationError = agent_err.into();
220        assert!(matches!(
221            op_err,
222            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
223        ));
224    }
225
226    #[test]
227    fn from_agent_error_schema_validation() {
228        let agent_err = AgentError::SchemaValidation {
229            expected: "a".to_string(),
230            got: "b".to_string(),
231        };
232        let op_err: OperationError = agent_err.into();
233        assert!(matches!(
234            op_err,
235            OperationError::Agent(AgentError::SchemaValidation { .. })
236        ));
237    }
238
239    #[test]
240    fn from_agent_error_timeout() {
241        let agent_err = AgentError::Timeout {
242            limit: Duration::from_secs(60),
243        };
244        let op_err: OperationError = agent_err.into();
245        assert!(matches!(
246            op_err,
247            OperationError::Agent(AgentError::Timeout { .. })
248        ));
249    }
250
251    #[test]
252    fn operation_error_implements_std_error() {
253        use std::error::Error;
254        let err = OperationError::Shell {
255            exit_code: 1,
256            stderr: "x".to_string(),
257        };
258        let _: &dyn Error = &err;
259    }
260
261    #[test]
262    fn agent_error_implements_std_error() {
263        use std::error::Error;
264        let err = AgentError::Timeout {
265            limit: Duration::from_secs(60),
266        };
267        let _: &dyn Error = &err;
268    }
269
270    #[test]
271    fn empty_stderr_edge_case() {
272        let err = OperationError::Shell {
273            exit_code: 1,
274            stderr: String::new(),
275        };
276        assert_eq!(err.to_string(), "shell exited with code 1: ");
277    }
278
279    #[test]
280    fn multiline_stderr() {
281        let err = AgentError::ProcessFailed {
282            exit_code: 1,
283            stderr: "line1\nline2\nline3".to_string(),
284        };
285        assert!(err.to_string().contains("line1\nline2\nline3"));
286    }
287
288    #[test]
289    fn unicode_in_stderr() {
290        let err = OperationError::Shell {
291            exit_code: 1,
292            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
293        };
294        assert!(err.to_string().contains("\u{1F4A5}"));
295    }
296
297    #[test]
298    fn http_error_with_status_display() {
299        let err = OperationError::Http {
300            status: Some(500),
301            message: "internal server error".to_string(),
302        };
303        assert_eq!(
304            err.to_string(),
305            "http error (status 500): internal server error"
306        );
307    }
308
309    #[test]
310    fn http_error_without_status_display() {
311        let err = OperationError::Http {
312            status: None,
313            message: "connection refused".to_string(),
314        };
315        assert_eq!(err.to_string(), "http error: connection refused");
316    }
317
318    #[test]
319    fn http_error_empty_message() {
320        let err = OperationError::Http {
321            status: Some(404),
322            message: String::new(),
323        };
324        assert_eq!(err.to_string(), "http error (status 404): ");
325    }
326
327    #[test]
328    fn subsecond_duration_in_timeout_display() {
329        let err = OperationError::Timeout {
330            step: "fast".to_string(),
331            limit: Duration::from_millis(500),
332        };
333        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
334    }
335
336    #[test]
337    fn source_chains_agent_error() {
338        use std::error::Error;
339        let err = OperationError::Agent(AgentError::Timeout {
340            limit: Duration::from_secs(60),
341        });
342        assert!(err.source().is_some());
343    }
344
345    #[test]
346    fn source_none_for_shell() {
347        use std::error::Error;
348        let err = OperationError::Shell {
349            exit_code: 1,
350            stderr: "x".to_string(),
351        };
352        assert!(err.source().is_none());
353    }
354
355    #[test]
356    fn deserialize_helper_formats_correctly() {
357        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
358        match &err {
359            OperationError::Deserialize {
360                target_type,
361                reason,
362            } => {
363                assert!(target_type.contains("Vec"));
364                assert!(target_type.contains("String"));
365                assert_eq!(reason, "missing field");
366            }
367            _ => panic!("expected Deserialize variant"),
368        }
369    }
370
371    #[test]
372    fn deserialize_display_format() {
373        let err = OperationError::Deserialize {
374            target_type: "MyStruct".to_string(),
375            reason: "bad input".to_string(),
376        };
377        assert_eq!(
378            err.to_string(),
379            "failed to deserialize into MyStruct: bad input"
380        );
381    }
382
383    #[test]
384    fn agent_error_prompt_too_large_display() {
385        let err = AgentError::PromptTooLarge {
386            chars: 966_007,
387            estimated_tokens: 241_501,
388            model_limit: 200_000,
389        };
390        let msg = err.to_string();
391        assert!(msg.contains("966007 chars"));
392        assert!(msg.contains("241501 tokens"));
393        assert!(msg.contains("200000 tokens"));
394    }
395
396    #[test]
397    fn from_agent_error_prompt_too_large() {
398        let agent_err = AgentError::PromptTooLarge {
399            chars: 1_000_000,
400            estimated_tokens: 250_000,
401            model_limit: 200_000,
402        };
403        let op_err: OperationError = agent_err.into();
404        assert!(matches!(
405            op_err,
406            OperationError::Agent(AgentError::PromptTooLarge {
407                model_limit: 200_000,
408                ..
409            })
410        ));
411    }
412
413    #[test]
414    fn source_none_for_http_timeout_deserialize() {
415        use std::error::Error;
416        let http = OperationError::Http {
417            status: Some(500),
418            message: "x".to_string(),
419        };
420        assert!(http.source().is_none());
421
422        let timeout = OperationError::Timeout {
423            step: "x".to_string(),
424            limit: Duration::from_secs(1),
425        };
426        assert!(timeout.source().is_none());
427
428        let deser = OperationError::Deserialize {
429            target_type: "T".to_string(),
430            reason: "r".to_string(),
431        };
432        assert!(deser.source().is_none());
433    }
434}