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