ironflow_engine/context/error_handlers.rs
1//! Error handlers registered on [`WorkflowContext`].
2//!
3//! A handler registered with [`on_error`](WorkflowContext::on_error) fires once
4//! when a later step fails. Execution is best-effort: a handler failure is
5//! logged and never replaces the error that triggered it.
6
7use std::mem::take;
8use std::time::Instant;
9
10use chrono::Utc;
11use serde_json::json;
12use tracing::{info, warn};
13
14use ironflow_store::models::{NewStep, StepStatus, StepUpdate, step_trace_id};
15
16use crate::config::StepConfig;
17use crate::executor::execute_step_config_intercepted;
18use crate::log_sender::StepLogSender;
19
20use super::{OnErrorHandler, WorkflowContext};
21
22impl WorkflowContext {
23 /// Register an error handler that fires when any subsequent step fails.
24 ///
25 /// The handler is consumed after firing (fire-once). Multiple handlers
26 /// can be registered; they fire in registration order.
27 ///
28 /// Error handler execution is best-effort: if a handler fails, the error
29 /// is logged but the original step error is preserved. Error handler steps
30 /// appear in the run timeline with
31 /// [`Step::is_error_handler`](ironflow_store::models::Step::is_error_handler)
32 /// set to `true`.
33 ///
34 /// # Examples
35 ///
36 /// ```no_run
37 /// use ironflow_engine::context::WorkflowContext;
38 /// use ironflow_engine::config::ShellConfig;
39 /// use ironflow_engine::error::EngineError;
40 ///
41 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
42 /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
43 /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
44 /// # Ok(())
45 /// # }
46 /// ```
47 pub fn on_error(&mut self, name: &str, config: impl Into<StepConfig>) {
48 self.error_handlers.push(OnErrorHandler {
49 name: name.to_string(),
50 config: config.into(),
51 });
52 }
53
54 /// Drain the registered handlers and consume the positions they took in the
55 /// first execution, when a tolerated failure is replayed.
56 pub(super) fn skip_fired_error_handlers(&mut self) {
57 let fired = take(&mut self.error_handlers);
58 self.position += u32::try_from(fired.len()).unwrap_or(u32::MAX);
59 }
60
61 /// Remove all registered error handlers.
62 ///
63 /// # Examples
64 ///
65 /// ```no_run
66 /// use ironflow_engine::context::WorkflowContext;
67 /// use ironflow_engine::config::ShellConfig;
68 /// use ironflow_engine::error::EngineError;
69 ///
70 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
71 /// ctx.on_error("cleanup", ShellConfig::new("rm -rf /tmp/build"));
72 /// ctx.shell("build", ShellConfig::new("cargo build")).await?;
73 /// ctx.clear_error_handlers();
74 /// // cleanup will NOT fire if deploy fails
75 /// ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
76 /// # Ok(())
77 /// # }
78 /// ```
79 pub fn clear_error_handlers(&mut self) {
80 self.error_handlers.clear();
81 }
82
83 /// Execute all registered error handlers after a step failure.
84 ///
85 /// Drains the handler list (fire-once). Each handler creates its own
86 /// step record with `is_error_handler = true`. Handler failures are
87 /// logged but never propagated.
88 pub(super) async fn fire_error_handlers(
89 &mut self,
90 failed_step_name: &str,
91 error_msg: &str,
92 duration_ms: u64,
93 ) {
94 let handlers = take(&mut self.error_handlers);
95 if handlers.is_empty() {
96 return;
97 }
98
99 let error_context = json!({
100 "failed_step": failed_step_name,
101 "error": error_msg,
102 "duration_ms": duration_ms,
103 });
104
105 for handler in handlers {
106 let mut config = handler.config.clone();
107 inject_error_context(&mut config, failed_step_name, error_msg, duration_ms);
108
109 let position = self.position;
110 self.position += 1;
111
112 let trace_id = step_trace_id(self.run_id, &handler.name, position);
113 let step = match self
114 .store
115 .create_step(NewStep {
116 run_id: self.run_id,
117 trace_id,
118 name: handler.name.clone(),
119 kind: config.kind(),
120 position,
121 input: Some(error_context.clone()),
122 is_error_handler: true,
123 })
124 .await
125 {
126 Ok(step) => step,
127 Err(err) => {
128 warn!(
129 run_id = %self.run_id,
130 handler = %handler.name,
131 error = %err,
132 "failed to create error handler step"
133 );
134 continue;
135 }
136 };
137
138 if let Err(err) = self.start_step(step.id, Utc::now()).await {
139 warn!(
140 run_id = %self.run_id,
141 handler = %handler.name,
142 error = %err,
143 "failed to start error handler step"
144 );
145 continue;
146 }
147
148 let step_log_sender = self
149 .log_sender
150 .as_ref()
151 .map(|s| StepLogSender::new(s.clone(), self.run_id, step.id, handler.name.clone()));
152
153 let start = Instant::now();
154 let result = execute_step_config_intercepted(
155 &config,
156 &self.provider,
157 step_log_sender,
158 self.step_interceptor(),
159 )
160 .await;
161 let handler_duration = start.elapsed().as_millis() as u64;
162 let completed_at = Utc::now();
163
164 match result {
165 Ok(output) => {
166 if let Err(store_err) = self
167 .store
168 .update_step(
169 step.id,
170 StepUpdate {
171 status: Some(StepStatus::Completed),
172 output: Some(output.output),
173 duration_ms: Some(handler_duration),
174 cost_usd: Some(output.cost_usd),
175 completed_at: Some(completed_at),
176 ..StepUpdate::default()
177 },
178 )
179 .await
180 {
181 warn!(
182 run_id = %self.run_id,
183 handler = %handler.name,
184 error = %store_err,
185 "failed to persist error handler completion"
186 );
187 }
188
189 info!(
190 run_id = %self.run_id,
191 handler = %handler.name,
192 duration_ms = handler_duration,
193 "error handler completed"
194 );
195 }
196 Err(err) => {
197 if let Err(store_err) = self
198 .store
199 .update_step(
200 step.id,
201 StepUpdate {
202 status: Some(StepStatus::Failed),
203 error: Some(err.to_string()),
204 duration_ms: Some(handler_duration),
205 completed_at: Some(completed_at),
206 ..StepUpdate::default()
207 },
208 )
209 .await
210 {
211 warn!(
212 run_id = %self.run_id,
213 handler = %handler.name,
214 error = %store_err,
215 "failed to persist error handler failure"
216 );
217 }
218
219 warn!(
220 run_id = %self.run_id,
221 handler = %handler.name,
222 error = %err,
223 "error handler failed (original error preserved)"
224 );
225 }
226 }
227 }
228 }
229}
230
231/// Inject error context into a step config before executing it as an error handler.
232fn inject_error_context(
233 config: &mut StepConfig,
234 failed_step: &str,
235 error_msg: &str,
236 duration_ms: u64,
237) {
238 match config {
239 StepConfig::Shell(shell) => {
240 shell
241 .env
242 .push(("IRONFLOW_ERROR_STEP".to_string(), failed_step.to_string()));
243 shell
244 .env
245 .push(("IRONFLOW_ERROR_MESSAGE".to_string(), error_msg.to_string()));
246 shell.env.push((
247 "IRONFLOW_ERROR_DURATION_MS".to_string(),
248 duration_ms.to_string(),
249 ));
250 }
251 StepConfig::Agent(agent) => {
252 agent.prompt = format!(
253 "[Error Context]\nStep \"{}\" failed after {}ms:\n{}\n\n{}",
254 failed_step, duration_ms, error_msg, agent.prompt
255 );
256 }
257 StepConfig::Http(http) => {
258 http.headers
259 .push(("X-Ironflow-Error-Step".to_string(), failed_step.to_string()));
260 http.headers.push((
261 "X-Ironflow-Error-Message".to_string(),
262 error_msg.to_string(),
263 ));
264 }
265 StepConfig::Workflow(_)
266 | StepConfig::Approval(_)
267 | StepConfig::Decision(_)
268 | StepConfig::Delay(_) => {}
269 }
270}