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