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}{}", raw_response.as_ref().map(|r| { let end = r.floor_char_boundary(200); format!(" (raw response: {}...)", &r[..end]) }).unwrap_or_default())]
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        /// Raw text response from the agent, truncated to ~4000 bytes.
133        ///
134        /// When structured output extraction fails, the model may still have
135        /// produced useful text in the `result` field. This captures it so
136        /// callers can persist it for debugging (e.g. in the step output).
137        raw_response: Option<String>,
138    },
139
140    /// The agent stopped because it exhausted its configured USD budget.
141    ///
142    /// Distinct from [`SchemaValidation`](AgentError::SchemaValidation): retrying
143    /// costs money and cannot succeed, since the budget is already spent. Treated
144    /// as non-retryable by [`is_retryable`](crate::retry::is_retryable) at the
145    /// operation level and by the engine at the run level.
146    #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
147    BudgetExceeded {
148        /// Total cost reported by the provider before it stopped, in USD.
149        spent_usd: f64,
150        /// The configured `max_budget_usd` limit, in USD.
151        limit_usd: f64,
152        /// Verbose conversation trace captured before the budget ran out.
153        debug_messages: Vec<DebugMessage>,
154        /// Usage data reported alongside the budget error. Boxed to keep
155        /// `AgentError` small on the stack.
156        partial_usage: Box<PartialUsage>,
157    },
158
159    /// The prompt exceeds the model's context window.
160    ///
161    /// Returned before spawning the process when the estimated token count
162    /// exceeds the model's known limit.
163    ///
164    /// * `chars` - number of characters in the combined prompt (system + user).
165    /// * `estimated_tokens` - approximate token count (chars / 4).
166    /// * `model_limit` - the model's context window in tokens.
167    #[error(
168        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
169    )]
170    PromptTooLarge {
171        /// Number of characters in the prompt.
172        chars: usize,
173        /// Estimated token count (chars / 4 heuristic).
174        estimated_tokens: usize,
175        /// Model's context window in tokens.
176        model_limit: usize,
177    },
178
179    /// The agent did not complete within the configured timeout.
180    #[error("agent timed out after {limit:?}")]
181    Timeout {
182        /// The [`Duration`] that was exceeded.
183        limit: Duration,
184    },
185
186    /// The provider returned HTTP 429 Too Many Requests.
187    #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
188    RateLimited {
189        /// Provider name (e.g. `"openai"`, `"anthropic"`).
190        provider: String,
191        /// Value from the `Retry-After` header, if present.
192        retry_after_secs: Option<u64>,
193    },
194
195    /// The provider returned an unexpected HTTP error or a transport-level failure.
196    ///
197    /// When `status_code` is `0`, no HTTP response was received (connection failure,
198    /// DNS resolution error, TLS handshake failure, or response body read error).
199    #[error("{provider} HTTP {status_code}: {message}")]
200    HttpProvider {
201        /// Provider name (e.g. `"openai"`, `"nvidia"`).
202        provider: String,
203        /// HTTP status code, or `0` for transport-level failures.
204        status_code: u16,
205        /// Error message from the provider response body, or transport error description.
206        message: String,
207    },
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn shell_display_format() {
216        let err = OperationError::Shell {
217            exit_code: 127,
218            stderr: "command not found".to_string(),
219        };
220        assert_eq!(
221            err.to_string(),
222            "shell exited with code 127: command not found"
223        );
224    }
225
226    #[test]
227    fn agent_display_delegates_to_agent_error() {
228        let inner = AgentError::ProcessFailed {
229            exit_code: 1,
230            stderr: "boom".to_string(),
231        };
232        let err = OperationError::Agent(inner);
233        assert_eq!(
234            err.to_string(),
235            "agent error: claude process exited with code 1: boom"
236        );
237    }
238
239    #[test]
240    fn timeout_display_format() {
241        let err = OperationError::Timeout {
242            step: "build".to_string(),
243            limit: Duration::from_secs(30),
244        };
245        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
246    }
247
248    #[test]
249    fn agent_error_process_failed_display_zero_exit_code() {
250        let err = AgentError::ProcessFailed {
251            exit_code: 0,
252            stderr: "unexpected".to_string(),
253        };
254        assert_eq!(
255            err.to_string(),
256            "claude process exited with code 0: unexpected"
257        );
258    }
259
260    #[test]
261    fn agent_error_process_failed_display_negative_exit_code() {
262        let err = AgentError::ProcessFailed {
263            exit_code: -1,
264            stderr: "killed".to_string(),
265        };
266        assert!(err.to_string().contains("-1"));
267    }
268
269    #[test]
270    fn agent_error_schema_validation_display() {
271        let err = AgentError::SchemaValidation {
272            expected: "object".to_string(),
273            got: "string".to_string(),
274            debug_messages: Vec::new(),
275            partial_usage: Box::default(),
276            raw_response: None,
277        };
278        assert_eq!(
279            err.to_string(),
280            "schema validation failed: expected object, got string"
281        );
282    }
283
284    #[test]
285    fn agent_error_timeout_display() {
286        let err = AgentError::Timeout {
287            limit: Duration::from_secs(300),
288        };
289        assert_eq!(err.to_string(), "agent timed out after 300s");
290    }
291
292    #[test]
293    fn from_agent_error_process_failed() {
294        let agent_err = AgentError::ProcessFailed {
295            exit_code: 42,
296            stderr: "fail".to_string(),
297        };
298        let op_err: OperationError = agent_err.into();
299        assert!(matches!(
300            op_err,
301            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
302        ));
303    }
304
305    #[test]
306    fn from_agent_error_schema_validation() {
307        let agent_err = AgentError::SchemaValidation {
308            expected: "a".to_string(),
309            got: "b".to_string(),
310            debug_messages: Vec::new(),
311            partial_usage: Box::default(),
312            raw_response: None,
313        };
314        let op_err: OperationError = agent_err.into();
315        assert!(matches!(
316            op_err,
317            OperationError::Agent(AgentError::SchemaValidation { .. })
318        ));
319    }
320
321    #[test]
322    fn from_agent_error_timeout() {
323        let agent_err = AgentError::Timeout {
324            limit: Duration::from_secs(60),
325        };
326        let op_err: OperationError = agent_err.into();
327        assert!(matches!(
328            op_err,
329            OperationError::Agent(AgentError::Timeout { .. })
330        ));
331    }
332
333    #[test]
334    fn operation_error_implements_std_error() {
335        use std::error::Error;
336        let err = OperationError::Shell {
337            exit_code: 1,
338            stderr: "x".to_string(),
339        };
340        let _: &dyn Error = &err;
341    }
342
343    #[test]
344    fn agent_error_implements_std_error() {
345        use std::error::Error;
346        let err = AgentError::Timeout {
347            limit: Duration::from_secs(60),
348        };
349        let _: &dyn Error = &err;
350    }
351
352    #[test]
353    fn empty_stderr_edge_case() {
354        let err = OperationError::Shell {
355            exit_code: 1,
356            stderr: String::new(),
357        };
358        assert_eq!(err.to_string(), "shell exited with code 1: ");
359    }
360
361    #[test]
362    fn multiline_stderr() {
363        let err = AgentError::ProcessFailed {
364            exit_code: 1,
365            stderr: "line1\nline2\nline3".to_string(),
366        };
367        assert!(err.to_string().contains("line1\nline2\nline3"));
368    }
369
370    #[test]
371    fn unicode_in_stderr() {
372        let err = OperationError::Shell {
373            exit_code: 1,
374            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
375        };
376        assert!(err.to_string().contains("\u{1F4A5}"));
377    }
378
379    #[test]
380    fn http_error_with_status_display() {
381        let err = OperationError::Http {
382            status: Some(500),
383            message: "internal server error".to_string(),
384        };
385        assert_eq!(
386            err.to_string(),
387            "http error (status 500): internal server error"
388        );
389    }
390
391    #[test]
392    fn http_error_without_status_display() {
393        let err = OperationError::Http {
394            status: None,
395            message: "connection refused".to_string(),
396        };
397        assert_eq!(err.to_string(), "http error: connection refused");
398    }
399
400    #[test]
401    fn http_error_empty_message() {
402        let err = OperationError::Http {
403            status: Some(404),
404            message: String::new(),
405        };
406        assert_eq!(err.to_string(), "http error (status 404): ");
407    }
408
409    #[test]
410    fn subsecond_duration_in_timeout_display() {
411        let err = OperationError::Timeout {
412            step: "fast".to_string(),
413            limit: Duration::from_millis(500),
414        };
415        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
416    }
417
418    #[test]
419    fn source_chains_agent_error() {
420        use std::error::Error;
421        let err = OperationError::Agent(AgentError::Timeout {
422            limit: Duration::from_secs(60),
423        });
424        assert!(err.source().is_some());
425    }
426
427    #[test]
428    fn source_none_for_shell() {
429        use std::error::Error;
430        let err = OperationError::Shell {
431            exit_code: 1,
432            stderr: "x".to_string(),
433        };
434        assert!(err.source().is_none());
435    }
436
437    #[test]
438    fn deserialize_helper_formats_correctly() {
439        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
440        match &err {
441            OperationError::Deserialize {
442                target_type,
443                reason,
444            } => {
445                assert!(target_type.contains("Vec"));
446                assert!(target_type.contains("String"));
447                assert_eq!(reason, "missing field");
448            }
449            _ => panic!("expected Deserialize variant"),
450        }
451    }
452
453    #[test]
454    fn deserialize_display_format() {
455        let err = OperationError::Deserialize {
456            target_type: "MyStruct".to_string(),
457            reason: "bad input".to_string(),
458        };
459        assert_eq!(
460            err.to_string(),
461            "failed to deserialize into MyStruct: bad input"
462        );
463    }
464
465    #[test]
466    fn agent_error_prompt_too_large_display() {
467        let err = AgentError::PromptTooLarge {
468            chars: 966_007,
469            estimated_tokens: 241_501,
470            model_limit: 200_000,
471        };
472        let msg = err.to_string();
473        assert!(msg.contains("966007 chars"));
474        assert!(msg.contains("241501 tokens"));
475        assert!(msg.contains("200000 tokens"));
476    }
477
478    #[test]
479    fn from_agent_error_prompt_too_large() {
480        let agent_err = AgentError::PromptTooLarge {
481            chars: 1_000_000,
482            estimated_tokens: 250_000,
483            model_limit: 200_000,
484        };
485        let op_err: OperationError = agent_err.into();
486        assert!(matches!(
487            op_err,
488            OperationError::Agent(AgentError::PromptTooLarge {
489                model_limit: 200_000,
490                ..
491            })
492        ));
493    }
494
495    #[test]
496    fn source_none_for_http_timeout_deserialize() {
497        use std::error::Error;
498        let http = OperationError::Http {
499            status: Some(500),
500            message: "x".to_string(),
501        };
502        assert!(http.source().is_none());
503
504        let timeout = OperationError::Timeout {
505            step: "x".to_string(),
506            limit: Duration::from_secs(1),
507        };
508        assert!(timeout.source().is_none());
509
510        let deser = OperationError::Deserialize {
511            target_type: "T".to_string(),
512            reason: "r".to_string(),
513        };
514        assert!(deser.source().is_none());
515    }
516
517    #[test]
518    fn schema_validation_raw_response_preserved() {
519        let err = AgentError::SchemaValidation {
520            expected: "structured_output field".to_string(),
521            got: "null".to_string(),
522            debug_messages: Vec::new(),
523            partial_usage: Box::default(),
524            raw_response: Some("The model said something useful".to_string()),
525        };
526        match err {
527            AgentError::SchemaValidation { raw_response, .. } => {
528                assert_eq!(
529                    raw_response.as_deref(),
530                    Some("The model said something useful")
531                );
532            }
533            _ => panic!("expected SchemaValidation"),
534        }
535    }
536
537    #[test]
538    fn schema_validation_raw_response_none_by_default() {
539        let err = AgentError::SchemaValidation {
540            expected: "a".to_string(),
541            got: "b".to_string(),
542            debug_messages: Vec::new(),
543            partial_usage: Box::default(),
544            raw_response: None,
545        };
546        match err {
547            AgentError::SchemaValidation { raw_response, .. } => {
548                assert!(raw_response.is_none());
549            }
550            _ => panic!("expected SchemaValidation"),
551        }
552    }
553}