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/// Partial usage data from a failed agent invocation.
85///
86/// When an agent step fails (e.g. structured output extraction), the CLI
87/// may still report cost, duration, and token counts. This struct carries
88/// those values so callers can persist them even on error paths.
89#[derive(Debug, Default)]
90pub struct PartialUsage {
91    /// Total cost in USD reported by the CLI.
92    pub cost_usd: Option<f64>,
93    /// Wall-clock duration reported by the CLI, in milliseconds.
94    pub duration_ms: Option<u64>,
95    /// Input tokens consumed before the failure.
96    pub input_tokens: Option<u64>,
97    /// Output tokens generated before the failure.
98    pub output_tokens: Option<u64>,
99}
100
101/// Error specific to agent (AI provider) invocations.
102///
103/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
104/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
105#[derive(Debug, Error)]
106pub enum AgentError {
107    /// The agent process exited with a non-zero status code.
108    #[error("claude process exited with code {exit_code}: {stderr}")]
109    ProcessFailed {
110        /// Process exit code, or `-1` if spawning failed.
111        exit_code: i32,
112        /// Captured stderr.
113        stderr: String,
114    },
115
116    /// The agent output did not match the expected schema.
117    #[error("schema validation failed: expected {expected}, got {got}")]
118    SchemaValidation {
119        /// What was expected (e.g. `"structured_output field"`).
120        expected: String,
121        /// What was actually received.
122        got: String,
123        /// Verbose conversation trace captured before the validation failure.
124        ///
125        /// Populated when the agent ran in verbose (stream-json) mode so that
126        /// callers can persist the debug trail even on error paths.
127        debug_messages: Vec<DebugMessage>,
128        /// Partial usage data from the CLI response, available even though
129        /// structured output extraction failed. Boxed to keep `AgentError`
130        /// small on the stack.
131        partial_usage: Box<PartialUsage>,
132    },
133
134    /// The prompt exceeds the model's context window.
135    ///
136    /// Returned before spawning the process when the estimated token count
137    /// exceeds the model's known limit.
138    ///
139    /// * `chars` - number of characters in the combined prompt (system + user).
140    /// * `estimated_tokens` - approximate token count (chars / 4).
141    /// * `model_limit` - the model's context window in tokens.
142    #[error(
143        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
144    )]
145    PromptTooLarge {
146        /// Number of characters in the prompt.
147        chars: usize,
148        /// Estimated token count (chars / 4 heuristic).
149        estimated_tokens: usize,
150        /// Model's context window in tokens.
151        model_limit: usize,
152    },
153
154    /// The agent did not complete within the configured timeout.
155    #[error("agent timed out after {limit:?}")]
156    Timeout {
157        /// The [`Duration`] that was exceeded.
158        limit: Duration,
159    },
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn shell_display_format() {
168        let err = OperationError::Shell {
169            exit_code: 127,
170            stderr: "command not found".to_string(),
171        };
172        assert_eq!(
173            err.to_string(),
174            "shell exited with code 127: command not found"
175        );
176    }
177
178    #[test]
179    fn agent_display_delegates_to_agent_error() {
180        let inner = AgentError::ProcessFailed {
181            exit_code: 1,
182            stderr: "boom".to_string(),
183        };
184        let err = OperationError::Agent(inner);
185        assert_eq!(
186            err.to_string(),
187            "agent error: claude process exited with code 1: boom"
188        );
189    }
190
191    #[test]
192    fn timeout_display_format() {
193        let err = OperationError::Timeout {
194            step: "build".to_string(),
195            limit: Duration::from_secs(30),
196        };
197        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
198    }
199
200    #[test]
201    fn agent_error_process_failed_display_zero_exit_code() {
202        let err = AgentError::ProcessFailed {
203            exit_code: 0,
204            stderr: "unexpected".to_string(),
205        };
206        assert_eq!(
207            err.to_string(),
208            "claude process exited with code 0: unexpected"
209        );
210    }
211
212    #[test]
213    fn agent_error_process_failed_display_negative_exit_code() {
214        let err = AgentError::ProcessFailed {
215            exit_code: -1,
216            stderr: "killed".to_string(),
217        };
218        assert!(err.to_string().contains("-1"));
219    }
220
221    #[test]
222    fn agent_error_schema_validation_display() {
223        let err = AgentError::SchemaValidation {
224            expected: "object".to_string(),
225            got: "string".to_string(),
226            debug_messages: Vec::new(),
227            partial_usage: Box::default(),
228        };
229        assert_eq!(
230            err.to_string(),
231            "schema validation failed: expected object, got string"
232        );
233    }
234
235    #[test]
236    fn agent_error_timeout_display() {
237        let err = AgentError::Timeout {
238            limit: Duration::from_secs(300),
239        };
240        assert_eq!(err.to_string(), "agent timed out after 300s");
241    }
242
243    #[test]
244    fn from_agent_error_process_failed() {
245        let agent_err = AgentError::ProcessFailed {
246            exit_code: 42,
247            stderr: "fail".to_string(),
248        };
249        let op_err: OperationError = agent_err.into();
250        assert!(matches!(
251            op_err,
252            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
253        ));
254    }
255
256    #[test]
257    fn from_agent_error_schema_validation() {
258        let agent_err = AgentError::SchemaValidation {
259            expected: "a".to_string(),
260            got: "b".to_string(),
261            debug_messages: Vec::new(),
262            partial_usage: Box::default(),
263        };
264        let op_err: OperationError = agent_err.into();
265        assert!(matches!(
266            op_err,
267            OperationError::Agent(AgentError::SchemaValidation { .. })
268        ));
269    }
270
271    #[test]
272    fn from_agent_error_timeout() {
273        let agent_err = AgentError::Timeout {
274            limit: Duration::from_secs(60),
275        };
276        let op_err: OperationError = agent_err.into();
277        assert!(matches!(
278            op_err,
279            OperationError::Agent(AgentError::Timeout { .. })
280        ));
281    }
282
283    #[test]
284    fn operation_error_implements_std_error() {
285        use std::error::Error;
286        let err = OperationError::Shell {
287            exit_code: 1,
288            stderr: "x".to_string(),
289        };
290        let _: &dyn Error = &err;
291    }
292
293    #[test]
294    fn agent_error_implements_std_error() {
295        use std::error::Error;
296        let err = AgentError::Timeout {
297            limit: Duration::from_secs(60),
298        };
299        let _: &dyn Error = &err;
300    }
301
302    #[test]
303    fn empty_stderr_edge_case() {
304        let err = OperationError::Shell {
305            exit_code: 1,
306            stderr: String::new(),
307        };
308        assert_eq!(err.to_string(), "shell exited with code 1: ");
309    }
310
311    #[test]
312    fn multiline_stderr() {
313        let err = AgentError::ProcessFailed {
314            exit_code: 1,
315            stderr: "line1\nline2\nline3".to_string(),
316        };
317        assert!(err.to_string().contains("line1\nline2\nline3"));
318    }
319
320    #[test]
321    fn unicode_in_stderr() {
322        let err = OperationError::Shell {
323            exit_code: 1,
324            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
325        };
326        assert!(err.to_string().contains("\u{1F4A5}"));
327    }
328
329    #[test]
330    fn http_error_with_status_display() {
331        let err = OperationError::Http {
332            status: Some(500),
333            message: "internal server error".to_string(),
334        };
335        assert_eq!(
336            err.to_string(),
337            "http error (status 500): internal server error"
338        );
339    }
340
341    #[test]
342    fn http_error_without_status_display() {
343        let err = OperationError::Http {
344            status: None,
345            message: "connection refused".to_string(),
346        };
347        assert_eq!(err.to_string(), "http error: connection refused");
348    }
349
350    #[test]
351    fn http_error_empty_message() {
352        let err = OperationError::Http {
353            status: Some(404),
354            message: String::new(),
355        };
356        assert_eq!(err.to_string(), "http error (status 404): ");
357    }
358
359    #[test]
360    fn subsecond_duration_in_timeout_display() {
361        let err = OperationError::Timeout {
362            step: "fast".to_string(),
363            limit: Duration::from_millis(500),
364        };
365        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
366    }
367
368    #[test]
369    fn source_chains_agent_error() {
370        use std::error::Error;
371        let err = OperationError::Agent(AgentError::Timeout {
372            limit: Duration::from_secs(60),
373        });
374        assert!(err.source().is_some());
375    }
376
377    #[test]
378    fn source_none_for_shell() {
379        use std::error::Error;
380        let err = OperationError::Shell {
381            exit_code: 1,
382            stderr: "x".to_string(),
383        };
384        assert!(err.source().is_none());
385    }
386
387    #[test]
388    fn deserialize_helper_formats_correctly() {
389        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
390        match &err {
391            OperationError::Deserialize {
392                target_type,
393                reason,
394            } => {
395                assert!(target_type.contains("Vec"));
396                assert!(target_type.contains("String"));
397                assert_eq!(reason, "missing field");
398            }
399            _ => panic!("expected Deserialize variant"),
400        }
401    }
402
403    #[test]
404    fn deserialize_display_format() {
405        let err = OperationError::Deserialize {
406            target_type: "MyStruct".to_string(),
407            reason: "bad input".to_string(),
408        };
409        assert_eq!(
410            err.to_string(),
411            "failed to deserialize into MyStruct: bad input"
412        );
413    }
414
415    #[test]
416    fn agent_error_prompt_too_large_display() {
417        let err = AgentError::PromptTooLarge {
418            chars: 966_007,
419            estimated_tokens: 241_501,
420            model_limit: 200_000,
421        };
422        let msg = err.to_string();
423        assert!(msg.contains("966007 chars"));
424        assert!(msg.contains("241501 tokens"));
425        assert!(msg.contains("200000 tokens"));
426    }
427
428    #[test]
429    fn from_agent_error_prompt_too_large() {
430        let agent_err = AgentError::PromptTooLarge {
431            chars: 1_000_000,
432            estimated_tokens: 250_000,
433            model_limit: 200_000,
434        };
435        let op_err: OperationError = agent_err.into();
436        assert!(matches!(
437            op_err,
438            OperationError::Agent(AgentError::PromptTooLarge {
439                model_limit: 200_000,
440                ..
441            })
442        ));
443    }
444
445    #[test]
446    fn source_none_for_http_timeout_deserialize() {
447        use std::error::Error;
448        let http = OperationError::Http {
449            status: Some(500),
450            message: "x".to_string(),
451        };
452        assert!(http.source().is_none());
453
454        let timeout = OperationError::Timeout {
455            step: "x".to_string(),
456            limit: Duration::from_secs(1),
457        };
458        assert!(timeout.source().is_none());
459
460        let deser = OperationError::Deserialize {
461            target_type: "T".to_string(),
462            reason: "r".to_string(),
463        };
464        assert!(deser.source().is_none());
465    }
466}