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