Skip to main content

camel_processor/
do_try_segment.rs

1//! ## Stop semantics (ADR-0025)
2//!
3//! This segment implements `OutcomePipeline` and propagates `PipelineOutcome::Stopped(ex)`
4//! with the exchange state intact. See ADR-0025 §3.
5
6use crate::do_try::CatchMatcher;
7use camel_api::error_handler::ExceptionDisposition;
8use camel_api::outcome_pipeline::OutcomePipeline;
9use camel_api::pipeline_outcome::PipelineOutcome;
10use camel_api::{CamelError, Exchange, FilterPredicate};
11use std::future::Future;
12use std::pin::Pin;
13
14/// - `Handled`: the exchange continues through finally and downstream.
15/// - `Propagate`: the catch body runs for side effects, finally runs with the
16///   catch body's exchange, and the original try-error re-throws as `Failed`.
17#[derive(Clone)]
18pub struct CatchClauseSegment {
19    pub matcher: CatchMatcher,
20    pub on_when: Option<FilterPredicate>,
21    pub body: camel_api::OutcomeSegment,
22    pub disposition: ExceptionDisposition,
23}
24
25/// Compilable segment for a `doFinally` clause within a `DoTrySegment`.
26#[derive(Clone)]
27pub struct FinallyClauseSegment {
28    pub on_when: Option<FilterPredicate>,
29    pub body: camel_api::OutcomeSegment,
30}
31
32/// Outcome-aware structural EIP segment for the doTry/doCatch/doFinally pattern.
33///
34/// Operates at the `PipelineOutcome` layer so that `Stopped(ex)` from a
35/// sub-step (e.g. Stop EIP) is preserved with the exchange including all
36/// mutations. See ADR-0025 §5.1 for full semantics.
37pub struct DoTrySegment {
38    pub try_body: camel_api::OutcomeSegment,
39    pub catches: Vec<CatchClauseSegment>,
40    pub finally: Option<FinallyClauseSegment>,
41}
42
43impl Clone for DoTrySegment {
44    fn clone(&self) -> Self {
45        Self {
46            try_body: self.try_body.clone(),
47            catches: self.catches.clone(),
48            finally: self.finally.clone(),
49        }
50    }
51}
52
53/// Narrow outcome type for `run_finally_body` so the compiler enforces
54/// exhaustiveness at the two call sites instead of `unreachable!()`.
55/// This is a private, transient type; the `Stopped` exchange is boxed to keep
56/// the `Err` variant of `run_finally_body` small (clippy::result_large_err).
57enum FinallyOutcome {
58    Stopped(Box<Exchange>),
59    Failed(CamelError),
60}
61
62/// Run the finally body if present and on_when permits.
63/// Free function (not a method) to avoid borrow conflict with
64/// `self.catches.iter_mut()` inside the catch loop.
65/// The `FinallyOutcome` variants move the `Exchange` (the catch loop needs
66/// its state); boxing would rewrite the segment call chain for a lint
67/// threshold.
68#[allow(clippy::result_large_err)]
69async fn run_finally_body(
70    finally: &mut Option<FinallyClauseSegment>,
71    ex: Exchange,
72) -> Result<Exchange, FinallyOutcome> {
73    let Some(f) = finally.as_mut() else {
74        return Ok(ex);
75    };
76    if !f.on_when.as_ref().map(|p| p(&ex)).unwrap_or(true) {
77        return Ok(ex);
78    }
79    match f.body.run(ex).await {
80        PipelineOutcome::Completed(e) => Ok(e),
81        PipelineOutcome::Stopped(e) => Err(FinallyOutcome::Stopped(Box::new(e))),
82        PipelineOutcome::Failed(e) => Err(FinallyOutcome::Failed(e)),
83    }
84}
85
86impl OutcomePipeline for DoTrySegment {
87    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
88        Box::new(self.clone())
89    }
90
91    fn run<'a>(
92        &'a mut self,
93        exchange: Exchange,
94    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
95        Box::pin(async move {
96            // Pre-clone for the "no catch matched" path; `exchange` itself is
97            // consumed by `try_body.run(...)` below.
98            let exchange_for_unmatched = exchange.clone();
99
100            // 1. Run try_body. If Stopped, return immediately (skip catch AND finally).
101            let try_outcome = self.try_body.run(exchange).await;
102            let returned_ex = match try_outcome {
103                PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
104                PipelineOutcome::Completed(ex) => ex,
105                PipelineOutcome::Failed(err) => {
106                    // 2. try failed — try each catch in order. Walk the catch
107                    // chain starting from the failed try-exchange. On the first
108                    // matching catch whose `on_when` is true (or unset), run it.
109                    let mut current_ex = exchange_for_unmatched;
110                    current_ex.set_error(err.clone());
111                    for catch in self.catches.iter_mut() {
112                        if catch.matcher.matches(&err, &current_ex)
113                            && catch
114                                .on_when
115                                .as_ref()
116                                .map(|p| p(&current_ex))
117                                .unwrap_or(true)
118                        {
119                            match catch.body.run(current_ex).await {
120                                // Stop in catch: skip finally AND outer route halts.
121                                PipelineOutcome::Stopped(stopped_ex) => {
122                                    return PipelineOutcome::Stopped(stopped_ex);
123                                }
124                                // Catch handled — exit catch chain with this exchange.
125                                PipelineOutcome::Completed(next) => {
126                                    match catch.disposition {
127                                        ExceptionDisposition::Handled => {
128                                            current_ex = next;
129                                            break;
130                                        }
131                                        ExceptionDisposition::Continued => {
132                                            current_ex = next;
133                                            break;
134                                        }
135                                        // Propagate and any future variant run finally then
136                                        // surface the original error (fail-closed).
137                                        _ => {
138                                            match run_finally_body(&mut self.finally, next).await {
139                                                Ok(_) => {}
140                                                Err(FinallyOutcome::Stopped(e)) => {
141                                                    return PipelineOutcome::Stopped(*e);
142                                                }
143                                                Err(FinallyOutcome::Failed(_finally_err)) => {
144                                                    tracing::warn!(
145                                                        error = %err,
146                                                        "doFinally threw during Propagate; \
147                                                         restoring original"
148                                                    );
149                                                    return PipelineOutcome::Failed(err);
150                                                }
151                                            }
152                                            return PipelineOutcome::Failed(err);
153                                        }
154                                    }
155                                }
156                                // Catch-body Failed: surface THAT error to outer
157                                // route. Per ADR-0025 invariant #4 ("doTry is a
158                                // local error-handler island"), a failing catch
159                                // body propagates as Failed — it does NOT re-enter
160                                // the catch chain (no recursive catch-of-catch in
161                                // Camel). Skip remaining catches and finally.
162                                PipelineOutcome::Failed(catch_err) => {
163                                    return PipelineOutcome::Failed(catch_err);
164                                }
165                            }
166                        }
167                    }
168                    current_ex
169                }
170            };
171            // 3. Run finally if present (skip if try/catch returned Stopped —
172            // already returned above; skip if catch-body Failed — also returned).
173            match run_finally_body(&mut self.finally, returned_ex).await {
174                Ok(ex) => PipelineOutcome::Completed(ex),
175                Err(FinallyOutcome::Stopped(e)) => PipelineOutcome::Stopped(*e),
176                Err(FinallyOutcome::Failed(finally_err)) => {
177                    tracing::warn!(
178                        error = %finally_err,
179                        "doFinally threw during/after catch; surfacing finally error"
180                    );
181                    PipelineOutcome::Failed(finally_err)
182                }
183            }
184        })
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::do_try::CatchMatcher;
192    use camel_api::pipeline_outcome::PipelineOutcome;
193    use std::sync::Arc;
194    use std::sync::atomic::{AtomicU32, Ordering};
195
196    // ── Helpers for DoTrySegment tests ──
197
198    struct CompleteSegment;
199
200    impl OutcomePipeline for CompleteSegment {
201        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
202            Box::new(CompleteSegment)
203        }
204        fn run<'a>(
205            &'a mut self,
206            exchange: Exchange,
207        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
208            Box::pin(async move { PipelineOutcome::Completed(exchange) })
209        }
210    }
211
212    fn seg_complete() -> camel_api::OutcomeSegment {
213        camel_api::OutcomeSegment::new(Box::new(CompleteSegment))
214    }
215
216    struct FailSegment(CamelError);
217
218    impl OutcomePipeline for FailSegment {
219        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
220            Box::new(FailSegment(self.0.clone()))
221        }
222        fn run<'a>(
223            &'a mut self,
224            _exchange: Exchange,
225        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
226            let e = self.0.clone();
227            Box::pin(async move { PipelineOutcome::Failed(e) })
228        }
229    }
230
231    fn seg_fail(err: CamelError) -> camel_api::OutcomeSegment {
232        camel_api::OutcomeSegment::new(Box::new(FailSegment(err)))
233    }
234
235    struct MutateThenStop {
236        mutator: Arc<dyn Fn(&mut Exchange) + Send + Sync>,
237    }
238
239    impl OutcomePipeline for MutateThenStop {
240        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
241            Box::new(MutateThenStop {
242                mutator: Arc::clone(&self.mutator),
243            })
244        }
245        fn run<'a>(
246            &'a mut self,
247            mut exchange: Exchange,
248        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
249            let m = Arc::clone(&self.mutator);
250            Box::pin(async move {
251                m(&mut exchange);
252                PipelineOutcome::Stopped(exchange)
253            })
254        }
255    }
256
257    fn seg_stop_with(
258        mutator: impl Fn(&mut Exchange) + Send + Sync + 'static,
259    ) -> camel_api::OutcomeSegment {
260        camel_api::OutcomeSegment::new(Box::new(MutateThenStop {
261            mutator: Arc::new(mutator),
262        }))
263    }
264
265    struct RecordCall {
266        counter: Arc<AtomicU32>,
267    }
268
269    impl OutcomePipeline for RecordCall {
270        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
271            Box::new(RecordCall {
272                counter: Arc::clone(&self.counter),
273            })
274        }
275        fn run<'a>(
276            &'a mut self,
277            exchange: Exchange,
278        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
279            let c = Arc::clone(&self.counter);
280            Box::pin(async move {
281                c.fetch_add(1, Ordering::SeqCst);
282                PipelineOutcome::Completed(exchange)
283            })
284        }
285    }
286
287    fn seg_record(counter: Arc<AtomicU32>) -> camel_api::OutcomeSegment {
288        camel_api::OutcomeSegment::new(Box::new(RecordCall { counter }))
289    }
290
291    // ── 5 New tests for DoTrySegment (ADR-0025) ──
292
293    #[tokio::test]
294    async fn stop_inside_try_skips_catch_and_finally() {
295        let catch_call = Arc::new(AtomicU32::new(0));
296        let finally_call = Arc::new(AtomicU32::new(0));
297
298        let mut seg = DoTrySegment {
299            try_body: seg_stop_with(|ex| {
300                ex.set_property("mutated", camel_api::Value::Bool(true));
301            }),
302            catches: vec![CatchClauseSegment {
303                matcher: CatchMatcher::ByVariant(vec!["*".into()]),
304                on_when: None,
305                body: seg_record(catch_call.clone()),
306                disposition: ExceptionDisposition::Handled,
307            }],
308            finally: Some(FinallyClauseSegment {
309                on_when: None,
310                body: seg_record(finally_call.clone()),
311            }),
312        };
313
314        let result = seg.run(Exchange::default()).await;
315        match result {
316            PipelineOutcome::Stopped(ex) => {
317                assert_eq!(
318                    ex.properties.get("mutated"),
319                    Some(&camel_api::Value::Bool(true)),
320                    "try body mutation must be preserved in Stopped exchange"
321                );
322            }
323            other => panic!("expected Stopped, got {:?}", other),
324        }
325        assert_eq!(
326            catch_call.load(Ordering::SeqCst),
327            0,
328            "catch must NOT run when try stops"
329        );
330        assert_eq!(
331            finally_call.load(Ordering::SeqCst),
332            0,
333            "finally must NOT run when try stops"
334        );
335    }
336
337    #[tokio::test]
338    async fn stop_inside_catch_skips_finally() {
339        let finally_call = Arc::new(AtomicU32::new(0));
340
341        let mut seg = DoTrySegment {
342            try_body: seg_fail(CamelError::ProcessorError("boom".into())),
343            catches: vec![CatchClauseSegment {
344                matcher: CatchMatcher::ByVariant(vec!["ProcessorError".into()]),
345                on_when: None,
346                body: seg_stop_with(|ex| {
347                    ex.set_property("catch_mutated", camel_api::Value::Bool(true));
348                }),
349                disposition: ExceptionDisposition::Handled,
350            }],
351            finally: Some(FinallyClauseSegment {
352                on_when: None,
353                body: seg_record(finally_call.clone()),
354            }),
355        };
356
357        let result = seg.run(Exchange::default()).await;
358        match result {
359            PipelineOutcome::Stopped(ex) => {
360                assert_eq!(
361                    ex.properties.get("catch_mutated"),
362                    Some(&camel_api::Value::Bool(true)),
363                    "catch body mutation must be preserved in Stopped exchange"
364                );
365            }
366            other => panic!("expected Stopped, got {:?}", other),
367        }
368        assert_eq!(
369            finally_call.load(Ordering::SeqCst),
370            0,
371            "finally must NOT run when catch stops"
372        );
373    }
374
375    #[tokio::test]
376    async fn stop_inside_finally_stops_outer_route() {
377        let mut seg = DoTrySegment {
378            try_body: seg_complete(),
379            catches: vec![],
380            finally: Some(FinallyClauseSegment {
381                on_when: None,
382                body: seg_stop_with(|ex| {
383                    ex.set_property("finally_mutated", camel_api::Value::Bool(true));
384                }),
385            }),
386        };
387
388        let result = seg.run(Exchange::default()).await;
389        match result {
390            PipelineOutcome::Stopped(ex) => {
391                assert_eq!(
392                    ex.properties.get("finally_mutated"),
393                    Some(&camel_api::Value::Bool(true)),
394                    "finally body mutation must be preserved in Stopped exchange"
395                );
396            }
397            other => panic!("expected Stopped, got {:?}", other),
398        }
399    }
400
401    #[tokio::test]
402    async fn catch_on_when_false_falls_through_to_next_catch() {
403        let first_call = Arc::new(AtomicU32::new(0));
404        let second_call = Arc::new(AtomicU32::new(0));
405
406        let mut seg = DoTrySegment {
407            try_body: seg_fail(CamelError::Io("disk err".into())),
408            catches: vec![
409                CatchClauseSegment {
410                    matcher: CatchMatcher::ByVariant(vec!["Io".into()]),
411                    on_when: Some(FilterPredicate::new(|_ex| false)),
412                    body: seg_record(first_call.clone()),
413                    disposition: ExceptionDisposition::Handled,
414                },
415                CatchClauseSegment {
416                    matcher: CatchMatcher::ByVariant(vec!["*".into()]),
417                    on_when: None,
418                    body: seg_record(second_call.clone()),
419                    disposition: ExceptionDisposition::Handled,
420                },
421            ],
422            finally: None,
423        };
424
425        let result = seg.run(Exchange::default()).await;
426        assert!(
427            matches!(result, PipelineOutcome::Completed(_)),
428            "expected Completed after second catch"
429        );
430        assert_eq!(
431            first_call.load(Ordering::SeqCst),
432            0,
433            "first catch must NOT fire (on_when=false)"
434        );
435        assert_eq!(
436            second_call.load(Ordering::SeqCst),
437            1,
438            "second catch must fire"
439        );
440    }
441
442    #[tokio::test]
443    async fn finally_on_when_false_skips_finally_entirely() {
444        let finally_call = Arc::new(AtomicU32::new(0));
445        let mut ex = Exchange::default();
446        ex.set_property("try_set", camel_api::Value::Bool(true));
447
448        let mut seg = DoTrySegment {
449            try_body: seg_complete(),
450            catches: vec![],
451            finally: Some(FinallyClauseSegment {
452                on_when: Some(FilterPredicate::new(|_ex| false)),
453                body: seg_record(finally_call.clone()),
454            }),
455        };
456
457        let result = seg.run(ex).await;
458        match result {
459            PipelineOutcome::Completed(ex) => {
460                assert_eq!(
461                    ex.properties.get("try_set"),
462                    Some(&camel_api::Value::Bool(true)),
463                    "exchange state from try must be preserved"
464                );
465            }
466            other => panic!("expected Completed, got {:?}", other),
467        }
468        assert_eq!(
469            finally_call.load(Ordering::SeqCst),
470            0,
471            "finally must NOT run when on_when=false"
472        );
473    }
474}