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    /// Uncached input tokens consumed before the failure.
130    pub input_tokens: Option<u64>,
131    /// Input tokens served from the prompt cache before the failure.
132    pub cache_read_input_tokens: Option<u64>,
133    /// Input tokens written to the prompt cache before the failure.
134    pub cache_creation_input_tokens: Option<u64>,
135    /// Output tokens generated before the failure.
136    pub output_tokens: Option<u64>,
137}
138
139/// Error specific to agent (AI provider) invocations.
140///
141/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
142/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
143#[derive(Debug, Error)]
144pub enum AgentError {
145    /// The agent process exited with a non-zero status code.
146    #[error("claude process exited with code {exit_code}: {stderr}")]
147    ProcessFailed {
148        /// Process exit code, or `-1` if spawning failed.
149        exit_code: i32,
150        /// Captured stderr.
151        stderr: String,
152    },
153
154    /// The agent output did not match the expected schema.
155    #[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())]
156    SchemaValidation {
157        /// What was expected (e.g. `"structured_output field"`).
158        expected: String,
159        /// What was actually received.
160        got: String,
161        /// Verbose conversation trace captured before the validation failure.
162        ///
163        /// Populated when the agent ran in verbose (stream-json) mode so that
164        /// callers can persist the debug trail even on error paths.
165        debug_messages: Vec<DebugMessage>,
166        /// Partial usage data from the CLI response, available even though
167        /// structured output extraction failed. Boxed to keep `AgentError`
168        /// small on the stack.
169        partial_usage: Box<PartialUsage>,
170        /// Raw text response from the agent, truncated to ~4000 bytes.
171        ///
172        /// When structured output extraction fails, the model may still have
173        /// produced useful text in the `result` field. This captures it so
174        /// callers can persist it for debugging (e.g. in the step output).
175        raw_response: Option<String>,
176    },
177
178    /// The agent stopped because it exhausted its configured USD budget.
179    ///
180    /// Distinct from [`SchemaValidation`](AgentError::SchemaValidation): retrying
181    /// costs money and cannot succeed, since the budget is already spent. Treated
182    /// as non-retryable by [`is_retryable`](crate::retry::is_retryable) at the
183    /// operation level and by the engine at the run level.
184    #[error("agent budget exceeded: spent ${spent_usd:.4} of ${limit_usd:.4} limit")]
185    BudgetExceeded {
186        /// Total cost reported by the provider before it stopped, in USD.
187        spent_usd: f64,
188        /// The configured `max_budget_usd` limit, in USD.
189        limit_usd: f64,
190        /// Verbose conversation trace captured before the budget ran out.
191        debug_messages: Vec<DebugMessage>,
192        /// Usage data reported alongside the budget error. Boxed to keep
193        /// `AgentError` small on the stack.
194        partial_usage: Box<PartialUsage>,
195    },
196
197    /// The model API refused the request the Claude CLI sent.
198    ///
199    /// Built from a CLI result with `is_error: true`: the CLI reached the API,
200    /// got an error back and wrote it in place of an answer. Structured output
201    /// is never validated against it. [`is_retryable`](crate::retry::is_retryable)
202    /// retries it only when `status` is absent, 429 or 5xx (529 overloaded
203    /// included): a 4xx such as `claude_code_version_too_old` or an unknown
204    /// model fails the same way on every attempt.
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// use ironflow_core::error::AgentError;
210    ///
211    /// let err = AgentError::Api {
212    ///     status: Some(400),
213    ///     code: Some("claude_code_version_too_old".to_string()),
214    ///     message: "API Error: 400 Claude Code 2.1.274 does not support this model".to_string(),
215    /// };
216    /// assert!(err.to_string().contains("claude_code_version_too_old"));
217    /// ```
218    #[error("claude api error ({}): {message}", api_error_context(*status, code.as_deref()))]
219    Api {
220        /// HTTP status the API answered with (`api_error_status`), absent when
221        /// the CLI did not report one.
222        status: Option<u16>,
223        /// API error code reported by the CLI (`api_error_code`), e.g.
224        /// `"claude_code_version_too_old"`.
225        code: Option<String>,
226        /// Error message printed by the CLI (its `result` field).
227        message: String,
228    },
229
230    /// The prompt exceeds the model's context window.
231    ///
232    /// Returned before spawning the process when the estimated token count
233    /// exceeds the model's known limit.
234    ///
235    /// * `chars` - number of characters in the combined prompt (system + user).
236    /// * `estimated_tokens` - approximate token count (chars / 4).
237    /// * `model_limit` - the model's context window in tokens.
238    #[error(
239        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
240    )]
241    PromptTooLarge {
242        /// Number of characters in the prompt.
243        chars: usize,
244        /// Estimated token count (chars / 4 heuristic).
245        estimated_tokens: usize,
246        /// Model's context window in tokens.
247        model_limit: usize,
248    },
249
250    /// The agent did not complete within the configured timeout.
251    #[error("agent timed out after {limit:?}")]
252    Timeout {
253        /// The [`Duration`] that was exceeded.
254        limit: Duration,
255    },
256
257    /// The provider returned HTTP 429 Too Many Requests.
258    #[error("rate limited by {provider}, retry after {retry_after_secs:?}s")]
259    RateLimited {
260        /// Provider name (e.g. `"openai"`, `"anthropic"`).
261        provider: String,
262        /// Value from the `Retry-After` header, if present.
263        retry_after_secs: Option<u64>,
264    },
265
266    /// The provider returned an unexpected HTTP error or a transport-level failure.
267    ///
268    /// When `status_code` is `0`, no HTTP response was received (connection failure,
269    /// DNS resolution error, TLS handshake failure, or response body read error).
270    #[error("{provider} HTTP {status_code}: {message}")]
271    HttpProvider {
272        /// Provider name (e.g. `"openai"`, `"nvidia"`).
273        provider: String,
274        /// HTTP status code, or `0` for transport-level failures.
275        status_code: u16,
276        /// Error message from the provider response body, or transport error description.
277        message: String,
278    },
279
280    /// The step asked for a tool profile the provider does not have.
281    ///
282    /// Never falls back to other tools: a typo must not widen what the model
283    /// can do. Deterministic, so never retried.
284    #[error(
285        "unknown tool profile '{profile}' (registered profiles: {})",
286        list_or_none(available)
287    )]
288    UnknownToolProfile {
289        /// The profile the step asked for.
290        profile: String,
291        /// Profiles registered on the provider, sorted.
292        available: Vec<String>,
293    },
294
295    /// The step asked for a tool profile, but the provider cannot apply one.
296    ///
297    /// Claude CLI providers pick their tools with `allowed_tools` and MCP
298    /// configuration; they refuse a profile rather than ignore it.
299    #[error(
300        "{provider} does not support tool profiles (step asked for '{profile}'): pick its tools with allow_tool or an MCP config"
301    )]
302    ToolProfileUnsupported {
303        /// Provider that refused the profile (e.g. `"claude-code"`).
304        provider: String,
305        /// The profile the step asked for.
306        profile: String,
307    },
308}
309
310fn api_error_context(status: Option<u16>, code: Option<&str>) -> String {
311    let status = status.map_or_else(|| "no status".to_string(), |s| format!("status {s}"));
312    match code {
313        Some(code) => format!("{status}, api_error_code {code}"),
314        None => status,
315    }
316}
317
318fn list_or_none(names: &[String]) -> String {
319    if names.is_empty() {
320        "none".to_string()
321    } else {
322        names.join(", ")
323    }
324}
325
326/// Error raised when accessing a typed answer on a
327/// [`DecisionOutput`](crate::decision::DecisionOutput) by name.
328///
329/// Distinct from [`AgentError`]: this is a lookup error on an already-received
330/// decision result, not a backend failure.
331///
332/// # Examples
333///
334/// ```
335/// use ironflow_core::error::DecisionError;
336///
337/// let err = DecisionError::NotFound("dept".to_string());
338/// assert_eq!(err.to_string(), "no decision answer named 'dept'");
339/// ```
340#[derive(Debug, Error)]
341pub enum DecisionError {
342    /// No answer exists under the requested name.
343    #[error("no decision answer named '{0}'")]
344    NotFound(String),
345
346    /// An answer exists but is a different kind than requested.
347    #[error("decision answer '{name}' is a {actual}, not a {expected}")]
348    TypeMismatch {
349        /// The answer name that was looked up.
350        name: String,
351        /// The kind the caller requested (`"noul"`, `"choice"`, or `"score"`).
352        expected: &'static str,
353        /// The kind the answer actually is.
354        actual: &'static str,
355    },
356
357    /// A choice answer picked an option the question did not offer.
358    #[error("decision answer '{name}' picked '{choice}', which is not one of its options")]
359    UnknownChoice {
360        /// The answer name that was read.
361        name: String,
362        /// The option the provider returned.
363        choice: String,
364    },
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn shell_display_format() {
373        let err = OperationError::Shell {
374            exit_code: 127,
375            stderr: "command not found".to_string(),
376        };
377        assert_eq!(
378            err.to_string(),
379            "shell exited with code 127: command not found"
380        );
381    }
382
383    #[test]
384    fn agent_display_delegates_to_agent_error() {
385        let inner = AgentError::ProcessFailed {
386            exit_code: 1,
387            stderr: "boom".to_string(),
388        };
389        let err = OperationError::Agent(inner);
390        assert_eq!(
391            err.to_string(),
392            "agent error: claude process exited with code 1: boom"
393        );
394    }
395
396    #[test]
397    fn timeout_display_format() {
398        let err = OperationError::Timeout {
399            step: "build".to_string(),
400            limit: Duration::from_secs(30),
401        };
402        assert_eq!(err.to_string(), "step 'build' timed out after 30s");
403    }
404
405    #[test]
406    fn agent_error_process_failed_display_zero_exit_code() {
407        let err = AgentError::ProcessFailed {
408            exit_code: 0,
409            stderr: "unexpected".to_string(),
410        };
411        assert_eq!(
412            err.to_string(),
413            "claude process exited with code 0: unexpected"
414        );
415    }
416
417    #[test]
418    fn agent_error_process_failed_display_negative_exit_code() {
419        let err = AgentError::ProcessFailed {
420            exit_code: -1,
421            stderr: "killed".to_string(),
422        };
423        assert!(err.to_string().contains("-1"));
424    }
425
426    #[test]
427    fn agent_error_schema_validation_display() {
428        let err = AgentError::SchemaValidation {
429            expected: "object".to_string(),
430            got: "string".to_string(),
431            debug_messages: Vec::new(),
432            partial_usage: Box::default(),
433            raw_response: None,
434        };
435        assert_eq!(
436            err.to_string(),
437            "schema validation failed: expected object, got string"
438        );
439    }
440
441    #[test]
442    fn api_error_display_with_status_and_code() {
443        let err = AgentError::Api {
444            status: Some(400),
445            code: Some("claude_code_version_too_old".to_string()),
446            message: "API Error: 400 too old".to_string(),
447        };
448        assert_eq!(
449            err.to_string(),
450            "claude api error (status 400, api_error_code claude_code_version_too_old): API Error: 400 too old"
451        );
452    }
453
454    #[test]
455    fn api_error_display_without_status_or_code() {
456        let err = AgentError::Api {
457            status: None,
458            code: None,
459            message: "API Error: Connection error.".to_string(),
460        };
461        assert_eq!(
462            err.to_string(),
463            "claude api error (no status): API Error: Connection error."
464        );
465    }
466
467    #[test]
468    fn agent_error_timeout_display() {
469        let err = AgentError::Timeout {
470            limit: Duration::from_secs(300),
471        };
472        assert_eq!(err.to_string(), "agent timed out after 300s");
473    }
474
475    #[test]
476    fn from_agent_error_process_failed() {
477        let agent_err = AgentError::ProcessFailed {
478            exit_code: 42,
479            stderr: "fail".to_string(),
480        };
481        let op_err: OperationError = agent_err.into();
482        assert!(matches!(
483            op_err,
484            OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
485        ));
486    }
487
488    #[test]
489    fn from_agent_error_schema_validation() {
490        let agent_err = AgentError::SchemaValidation {
491            expected: "a".to_string(),
492            got: "b".to_string(),
493            debug_messages: Vec::new(),
494            partial_usage: Box::default(),
495            raw_response: None,
496        };
497        let op_err: OperationError = agent_err.into();
498        assert!(matches!(
499            op_err,
500            OperationError::Agent(AgentError::SchemaValidation { .. })
501        ));
502    }
503
504    #[test]
505    fn from_agent_error_timeout() {
506        let agent_err = AgentError::Timeout {
507            limit: Duration::from_secs(60),
508        };
509        let op_err: OperationError = agent_err.into();
510        assert!(matches!(
511            op_err,
512            OperationError::Agent(AgentError::Timeout { .. })
513        ));
514    }
515
516    #[test]
517    fn operation_error_implements_std_error() {
518        use std::error::Error;
519        let err = OperationError::Shell {
520            exit_code: 1,
521            stderr: "x".to_string(),
522        };
523        let _: &dyn Error = &err;
524    }
525
526    #[test]
527    fn agent_error_implements_std_error() {
528        use std::error::Error;
529        let err = AgentError::Timeout {
530            limit: Duration::from_secs(60),
531        };
532        let _: &dyn Error = &err;
533    }
534
535    #[test]
536    fn empty_stderr_edge_case() {
537        let err = OperationError::Shell {
538            exit_code: 1,
539            stderr: String::new(),
540        };
541        assert_eq!(err.to_string(), "shell exited with code 1: ");
542    }
543
544    #[test]
545    fn multiline_stderr() {
546        let err = AgentError::ProcessFailed {
547            exit_code: 1,
548            stderr: "line1\nline2\nline3".to_string(),
549        };
550        assert!(err.to_string().contains("line1\nline2\nline3"));
551    }
552
553    #[test]
554    fn unicode_in_stderr() {
555        let err = OperationError::Shell {
556            exit_code: 1,
557            stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
558        };
559        assert!(err.to_string().contains("\u{1F4A5}"));
560    }
561
562    #[test]
563    fn http_error_with_status_display() {
564        let err = OperationError::Http {
565            status: Some(500),
566            message: "internal server error".to_string(),
567        };
568        assert_eq!(
569            err.to_string(),
570            "http error (status 500): internal server error"
571        );
572    }
573
574    #[test]
575    fn http_error_without_status_display() {
576        let err = OperationError::Http {
577            status: None,
578            message: "connection refused".to_string(),
579        };
580        assert_eq!(err.to_string(), "http error: connection refused");
581    }
582
583    #[test]
584    fn http_error_empty_message() {
585        let err = OperationError::Http {
586            status: Some(404),
587            message: String::new(),
588        };
589        assert_eq!(err.to_string(), "http error (status 404): ");
590    }
591
592    #[test]
593    fn subsecond_duration_in_timeout_display() {
594        let err = OperationError::Timeout {
595            step: "fast".to_string(),
596            limit: Duration::from_millis(500),
597        };
598        assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
599    }
600
601    #[test]
602    fn source_chains_agent_error() {
603        use std::error::Error;
604        let err = OperationError::Agent(AgentError::Timeout {
605            limit: Duration::from_secs(60),
606        });
607        assert!(err.source().is_some());
608    }
609
610    #[test]
611    fn source_none_for_shell() {
612        use std::error::Error;
613        let err = OperationError::Shell {
614            exit_code: 1,
615            stderr: "x".to_string(),
616        };
617        assert!(err.source().is_none());
618    }
619
620    #[test]
621    fn deserialize_helper_formats_correctly() {
622        let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
623        match &err {
624            OperationError::Deserialize {
625                target_type,
626                reason,
627            } => {
628                assert!(target_type.contains("Vec"));
629                assert!(target_type.contains("String"));
630                assert_eq!(reason, "missing field");
631            }
632            _ => panic!("expected Deserialize variant"),
633        }
634    }
635
636    #[test]
637    fn deserialize_display_format() {
638        let err = OperationError::Deserialize {
639            target_type: "MyStruct".to_string(),
640            reason: "bad input".to_string(),
641        };
642        assert_eq!(
643            err.to_string(),
644            "failed to deserialize into MyStruct: bad input"
645        );
646    }
647
648    #[test]
649    fn agent_error_prompt_too_large_display() {
650        let err = AgentError::PromptTooLarge {
651            chars: 966_007,
652            estimated_tokens: 241_501,
653            model_limit: 200_000,
654        };
655        let msg = err.to_string();
656        assert!(msg.contains("966007 chars"));
657        assert!(msg.contains("241501 tokens"));
658        assert!(msg.contains("200000 tokens"));
659    }
660
661    #[test]
662    fn from_agent_error_prompt_too_large() {
663        let agent_err = AgentError::PromptTooLarge {
664            chars: 1_000_000,
665            estimated_tokens: 250_000,
666            model_limit: 200_000,
667        };
668        let op_err: OperationError = agent_err.into();
669        assert!(matches!(
670            op_err,
671            OperationError::Agent(AgentError::PromptTooLarge {
672                model_limit: 200_000,
673                ..
674            })
675        ));
676    }
677
678    #[test]
679    fn source_none_for_http_timeout_deserialize() {
680        use std::error::Error;
681        let http = OperationError::Http {
682            status: Some(500),
683            message: "x".to_string(),
684        };
685        assert!(http.source().is_none());
686
687        let timeout = OperationError::Timeout {
688            step: "x".to_string(),
689            limit: Duration::from_secs(1),
690        };
691        assert!(timeout.source().is_none());
692
693        let deser = OperationError::Deserialize {
694            target_type: "T".to_string(),
695            reason: "r".to_string(),
696        };
697        assert!(deser.source().is_none());
698    }
699
700    #[test]
701    fn schema_validation_raw_response_preserved() {
702        let err = AgentError::SchemaValidation {
703            expected: "structured_output field".to_string(),
704            got: "null".to_string(),
705            debug_messages: Vec::new(),
706            partial_usage: Box::default(),
707            raw_response: Some("The model said something useful".to_string()),
708        };
709        match err {
710            AgentError::SchemaValidation { raw_response, .. } => {
711                assert_eq!(
712                    raw_response.as_deref(),
713                    Some("The model said something useful")
714                );
715            }
716            _ => panic!("expected SchemaValidation"),
717        }
718    }
719
720    #[test]
721    fn external_error_display() {
722        let err = OperationError::External {
723            origin: "git".to_string(),
724            message: "reference not found".to_string(),
725        };
726        assert_eq!(err.to_string(), "git error: reference not found");
727    }
728
729    #[test]
730    fn schema_validation_raw_response_none_by_default() {
731        let err = AgentError::SchemaValidation {
732            expected: "a".to_string(),
733            got: "b".to_string(),
734            debug_messages: Vec::new(),
735            partial_usage: Box::default(),
736            raw_response: None,
737        };
738        match err {
739            AgentError::SchemaValidation { raw_response, .. } => {
740                assert!(raw_response.is_none());
741            }
742            _ => panic!("expected SchemaValidation"),
743        }
744    }
745}