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