1use crate::do_try::CatchMatcher;
7use crate::do_try::chain_predicate_error;
8use crate::error_handler::record_span_error;
9use camel_api::error_handler::ExceptionDisposition;
10use camel_api::exchange::PROPERTY_EXCEPTION_HANDLED;
11use camel_api::outcome_pipeline::OutcomePipeline;
12use camel_api::pipeline_outcome::PipelineOutcome;
13use camel_api::{CamelError, Exchange, PredicateSource};
14use std::future::Future;
15use std::pin::Pin;
16
17#[derive(Clone)]
21pub struct CatchClauseSegment {
22 pub matcher: CatchMatcher,
23 pub on_when: Option<PredicateSource>,
24 pub body: camel_api::OutcomeSegment,
25 pub disposition: ExceptionDisposition,
26}
27
28#[derive(Clone)]
30pub struct FinallyClauseSegment {
31 pub on_when: Option<PredicateSource>,
32 pub body: camel_api::OutcomeSegment,
33}
34
35pub struct DoTrySegment {
41 pub try_body: camel_api::OutcomeSegment,
42 pub catches: Vec<CatchClauseSegment>,
43 pub finally: Option<FinallyClauseSegment>,
44}
45
46impl Clone for DoTrySegment {
47 fn clone(&self) -> Self {
48 Self {
49 try_body: self.try_body.clone(),
50 catches: self.catches.clone(),
51 finally: self.finally.clone(),
52 }
53 }
54}
55
56enum FinallyOutcome {
61 Stopped(Box<Exchange>),
62 Failed(CamelError),
63 PredicateFailed(CamelError),
66}
67
68#[allow(clippy::result_large_err)]
75async fn run_finally_body(
76 finally: &mut Option<FinallyClauseSegment>,
77 ex: Exchange,
78) -> Result<Exchange, FinallyOutcome> {
79 let Some(f) = finally.as_mut() else {
80 return Ok(ex);
81 };
82 if let Some(on_when) = &f.on_when {
83 match on_when.matches(&ex).await {
84 Ok(true) => {}
85 Ok(false) => return Ok(ex),
86 Err(err) => return Err(FinallyOutcome::PredicateFailed(err)),
87 }
88 }
89 match f.body.run(ex).await {
90 PipelineOutcome::Completed(e) => Ok(e),
91 PipelineOutcome::Stopped(e) => Err(FinallyOutcome::Stopped(Box::new(e))),
92 PipelineOutcome::Failed(e) => Err(FinallyOutcome::Failed(e)),
93 }
94}
95
96async fn fail_after_finally(
105 finally: &mut Option<FinallyClauseSegment>,
106 ex: Exchange,
107 failure: CamelError,
108) -> PipelineOutcome {
109 match run_finally_body(finally, ex).await {
110 Ok(_) => PipelineOutcome::Failed(failure),
111 Err(FinallyOutcome::Stopped(e)) => PipelineOutcome::Stopped(*e),
112 Err(FinallyOutcome::Failed(_finally_err)) => {
113 tracing::warn!(
114 error = %failure,
115 "doFinally threw; restoring previous error (Camel parity)"
116 );
117 PipelineOutcome::Failed(failure)
118 }
119 Err(FinallyOutcome::PredicateFailed(pred_err)) => PipelineOutcome::Failed(pred_err),
120 }
121}
122
123impl OutcomePipeline for DoTrySegment {
124 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
125 Box::new(self.clone())
126 }
127
128 fn run<'a>(
129 &'a mut self,
130 mut exchange: Exchange,
131 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
132 Box::pin(async move {
133 exchange.properties.remove(PROPERTY_EXCEPTION_HANDLED);
137 let exchange_for_unmatched = exchange.branch_clone();
140
141 let try_outcome = self.try_body.run(exchange).await;
143 let returned_ex = match try_outcome {
144 PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
145 PipelineOutcome::Completed(ex) => ex,
146 PipelineOutcome::Failed(err) => {
147 let mut current_ex = exchange_for_unmatched;
153 current_ex.set_error(err.clone());
154 let mut catch_matched = false;
158 for catch in self.catches.iter_mut() {
159 let matched = match catch.matcher.matches(&err, ¤t_ex).await {
160 Ok(m) => m,
161 Err(pred_err) => {
162 let chained = chain_predicate_error(pred_err, err);
163 return fail_after_finally(&mut self.finally, current_ex, chained)
164 .await;
165 }
166 };
167 if !matched {
168 continue;
169 }
170 if let Some(on_when) = &catch.on_when {
171 match on_when.matches(¤t_ex).await {
172 Ok(true) => {}
173 Ok(false) => continue,
174 Err(pred_err) => {
175 let chained = chain_predicate_error(pred_err, err);
176 return fail_after_finally(
177 &mut self.finally,
178 current_ex,
179 chained,
180 )
181 .await;
182 }
183 }
184 }
185 match catch.body.run(current_ex).await {
186 PipelineOutcome::Stopped(stopped_ex) => {
188 return PipelineOutcome::Stopped(stopped_ex);
189 }
190 PipelineOutcome::Completed(next) => {
192 match catch.disposition {
193 ExceptionDisposition::Handled => {
194 current_ex = next;
195 catch_matched = true;
196 break;
197 }
198 ExceptionDisposition::Continued => {
199 current_ex = next;
200 catch_matched = true;
201 break;
202 }
203 _ => {
206 return fail_after_finally(&mut self.finally, next, err)
207 .await;
208 }
209 }
210 }
211 PipelineOutcome::Failed(catch_err) => {
218 tracing::warn!(
225 original_error = %err,
226 catch_error = %catch_err,
227 "do_try catch block failed; catch error supersedes original"
228 );
229 record_span_error(&catch_err);
230 return PipelineOutcome::Failed(catch_err);
231 }
232 }
233 }
234 if catch_matched {
235 current_ex
238 } else {
239 return fail_after_finally(&mut self.finally, current_ex, err).await;
245 }
246 }
247 };
248 match run_finally_body(&mut self.finally, returned_ex).await {
251 Ok(ex) => PipelineOutcome::Completed(ex),
252 Err(FinallyOutcome::Stopped(e)) => PipelineOutcome::Stopped(*e),
253 Err(FinallyOutcome::Failed(finally_err)) => {
254 tracing::warn!(
255 error = %finally_err,
256 "doFinally threw during/after catch; surfacing finally error"
257 );
258 PipelineOutcome::Failed(finally_err)
259 }
260 Err(FinallyOutcome::PredicateFailed(pred_err)) => PipelineOutcome::Failed(pred_err),
263 }
264 })
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::do_try::CatchMatcher;
272 use crate::test_log_capture::{capture_debugs_with_span_records, captured_field, record_field};
273 use camel_api::pipeline_outcome::PipelineOutcome;
274 use std::sync::Arc;
275 use std::sync::atomic::{AtomicU32, Ordering};
276
277 struct CompleteSegment;
280
281 impl OutcomePipeline for CompleteSegment {
282 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
283 Box::new(CompleteSegment)
284 }
285 fn run<'a>(
286 &'a mut self,
287 exchange: Exchange,
288 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
289 Box::pin(async move { PipelineOutcome::Completed(exchange) })
290 }
291 }
292
293 fn seg_complete() -> camel_api::OutcomeSegment {
294 camel_api::OutcomeSegment::new(Box::new(CompleteSegment))
295 }
296
297 struct FailSegment(CamelError);
298
299 impl OutcomePipeline for FailSegment {
300 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
301 Box::new(FailSegment(self.0.clone()))
302 }
303 fn run<'a>(
304 &'a mut self,
305 _exchange: Exchange,
306 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
307 let e = self.0.clone();
308 Box::pin(async move { PipelineOutcome::Failed(e) })
309 }
310 }
311
312 fn seg_fail(err: CamelError) -> camel_api::OutcomeSegment {
313 camel_api::OutcomeSegment::new(Box::new(FailSegment(err)))
314 }
315
316 struct MutateThenStop {
317 mutator: Arc<dyn Fn(&mut Exchange) + Send + Sync>,
318 }
319
320 impl OutcomePipeline for MutateThenStop {
321 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
322 Box::new(MutateThenStop {
323 mutator: Arc::clone(&self.mutator),
324 })
325 }
326 fn run<'a>(
327 &'a mut self,
328 mut exchange: Exchange,
329 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
330 let m = Arc::clone(&self.mutator);
331 Box::pin(async move {
332 m(&mut exchange);
333 PipelineOutcome::Stopped(exchange)
334 })
335 }
336 }
337
338 fn seg_stop_with(
339 mutator: impl Fn(&mut Exchange) + Send + Sync + 'static,
340 ) -> camel_api::OutcomeSegment {
341 camel_api::OutcomeSegment::new(Box::new(MutateThenStop {
342 mutator: Arc::new(mutator),
343 }))
344 }
345
346 struct RecordCall {
347 counter: Arc<AtomicU32>,
348 }
349
350 impl OutcomePipeline for RecordCall {
351 fn clone_box(&self) -> Box<dyn OutcomePipeline> {
352 Box::new(RecordCall {
353 counter: Arc::clone(&self.counter),
354 })
355 }
356 fn run<'a>(
357 &'a mut self,
358 exchange: Exchange,
359 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
360 let c = Arc::clone(&self.counter);
361 Box::pin(async move {
362 c.fetch_add(1, Ordering::SeqCst);
363 PipelineOutcome::Completed(exchange)
364 })
365 }
366 }
367
368 fn seg_record(counter: Arc<AtomicU32>) -> camel_api::OutcomeSegment {
369 camel_api::OutcomeSegment::new(Box::new(RecordCall { counter }))
370 }
371
372 #[tokio::test]
375 async fn stop_inside_try_skips_catch_and_finally() {
376 let catch_call = Arc::new(AtomicU32::new(0));
377 let finally_call = Arc::new(AtomicU32::new(0));
378
379 let mut seg = DoTrySegment {
380 try_body: seg_stop_with(|ex| {
381 ex.set_property("mutated", camel_api::Value::Bool(true));
382 }),
383 catches: vec![CatchClauseSegment {
384 matcher: CatchMatcher::ByVariant(vec!["*".into()]),
385 on_when: None,
386 body: seg_record(catch_call.clone()),
387 disposition: ExceptionDisposition::Handled,
388 }],
389 finally: Some(FinallyClauseSegment {
390 on_when: None,
391 body: seg_record(finally_call.clone()),
392 }),
393 };
394
395 let result = seg.run(Exchange::default()).await;
396 match result {
397 PipelineOutcome::Stopped(ex) => {
398 assert_eq!(
399 ex.properties.get("mutated"),
400 Some(&camel_api::Value::Bool(true)),
401 "try body mutation must be preserved in Stopped exchange"
402 );
403 }
404 other => panic!("expected Stopped, got {:?}", other),
405 }
406 assert_eq!(
407 catch_call.load(Ordering::SeqCst),
408 0,
409 "catch must NOT run when try stops"
410 );
411 assert_eq!(
412 finally_call.load(Ordering::SeqCst),
413 0,
414 "finally must NOT run when try stops"
415 );
416 }
417
418 #[tokio::test]
419 async fn stop_inside_catch_skips_finally() {
420 let finally_call = Arc::new(AtomicU32::new(0));
421
422 let mut seg = DoTrySegment {
423 try_body: seg_fail(CamelError::ProcessorError("boom".into())),
424 catches: vec![CatchClauseSegment {
425 matcher: CatchMatcher::ByVariant(vec!["ProcessorError".into()]),
426 on_when: None,
427 body: seg_stop_with(|ex| {
428 ex.set_property("catch_mutated", camel_api::Value::Bool(true));
429 }),
430 disposition: ExceptionDisposition::Handled,
431 }],
432 finally: Some(FinallyClauseSegment {
433 on_when: None,
434 body: seg_record(finally_call.clone()),
435 }),
436 };
437
438 let result = seg.run(Exchange::default()).await;
439 match result {
440 PipelineOutcome::Stopped(ex) => {
441 assert_eq!(
442 ex.properties.get("catch_mutated"),
443 Some(&camel_api::Value::Bool(true)),
444 "catch body mutation must be preserved in Stopped exchange"
445 );
446 }
447 other => panic!("expected Stopped, got {:?}", other),
448 }
449 assert_eq!(
450 finally_call.load(Ordering::SeqCst),
451 0,
452 "finally must NOT run when catch stops"
453 );
454 }
455
456 #[tokio::test]
457 async fn stop_inside_finally_stops_outer_route() {
458 let mut seg = DoTrySegment {
459 try_body: seg_complete(),
460 catches: vec![],
461 finally: Some(FinallyClauseSegment {
462 on_when: None,
463 body: seg_stop_with(|ex| {
464 ex.set_property("finally_mutated", camel_api::Value::Bool(true));
465 }),
466 }),
467 };
468
469 let result = seg.run(Exchange::default()).await;
470 match result {
471 PipelineOutcome::Stopped(ex) => {
472 assert_eq!(
473 ex.properties.get("finally_mutated"),
474 Some(&camel_api::Value::Bool(true)),
475 "finally body mutation must be preserved in Stopped exchange"
476 );
477 }
478 other => panic!("expected Stopped, got {:?}", other),
479 }
480 }
481
482 #[tokio::test]
483 async fn catch_on_when_false_falls_through_to_next_catch() {
484 let first_call = Arc::new(AtomicU32::new(0));
485 let second_call = Arc::new(AtomicU32::new(0));
486
487 let mut seg = DoTrySegment {
488 try_body: seg_fail(CamelError::Io("disk err".into())),
489 catches: vec![
490 CatchClauseSegment {
491 matcher: CatchMatcher::ByVariant(vec!["Io".into()]),
492 on_when: Some(PredicateSource::Sync(FilterPredicate::new(|_ex| false))),
493 body: seg_record(first_call.clone()),
494 disposition: ExceptionDisposition::Handled,
495 },
496 CatchClauseSegment {
497 matcher: CatchMatcher::ByVariant(vec!["*".into()]),
498 on_when: None,
499 body: seg_record(second_call.clone()),
500 disposition: ExceptionDisposition::Handled,
501 },
502 ],
503 finally: None,
504 };
505
506 let result = seg.run(Exchange::default()).await;
507 assert!(
508 matches!(result, PipelineOutcome::Completed(_)),
509 "expected Completed after second catch"
510 );
511 assert_eq!(
512 first_call.load(Ordering::SeqCst),
513 0,
514 "first catch must NOT fire (on_when=false)"
515 );
516 assert_eq!(
517 second_call.load(Ordering::SeqCst),
518 1,
519 "second catch must fire"
520 );
521 }
522
523 #[tokio::test]
524 async fn finally_on_when_false_skips_finally_entirely() {
525 let finally_call = Arc::new(AtomicU32::new(0));
526 let mut ex = Exchange::default();
527 ex.set_property("try_set", camel_api::Value::Bool(true));
528
529 let mut seg = DoTrySegment {
530 try_body: seg_complete(),
531 catches: vec![],
532 finally: Some(FinallyClauseSegment {
533 on_when: Some(PredicateSource::Sync(FilterPredicate::new(|_ex| false))),
534 body: seg_record(finally_call.clone()),
535 }),
536 };
537
538 let result = seg.run(ex).await;
539 match result {
540 PipelineOutcome::Completed(ex) => {
541 assert_eq!(
542 ex.properties.get("try_set"),
543 Some(&camel_api::Value::Bool(true)),
544 "exchange state from try must be preserved"
545 );
546 }
547 other => panic!("expected Completed, got {:?}", other),
548 }
549 assert_eq!(
550 finally_call.load(Ordering::SeqCst),
551 0,
552 "finally must NOT run when on_when=false"
553 );
554 }
555
556 fn catch_fails_segment() -> DoTrySegment {
559 DoTrySegment {
560 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
561 catches: vec![CatchClauseSegment {
562 matcher: CatchMatcher::ByVariant(vec!["ProcessorError".into()]),
563 on_when: None,
564 body: seg_fail(CamelError::Io("catch-fail".into())),
565 disposition: ExceptionDisposition::Handled,
566 }],
567 finally: None,
568 }
569 }
570
571 #[test]
572 fn catch_body_failure_returns_catch_err_and_marks_span() {
573 let mut seg = catch_fails_segment();
574 let (result, captured, span_records) = capture_debugs_with_span_records(|| {
575 let span = tracing::info_span!("dotry_seg_test", error = tracing::field::Empty);
577 let _guard = span.enter();
578 tokio::runtime::Builder::new_current_thread()
579 .enable_all()
580 .build()
581 .expect("current-thread runtime")
582 .block_on(async { seg.run(Exchange::default()).await })
583 });
584
585 assert!(
586 matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
587 "catch failure must surface the catch error as Failed(Io), got: {result:?}"
588 );
589 assert!(
593 span_records.iter().any(|line| {
594 captured_field(line, "error").is_some_and(|v| v.contains("catch-fail"))
595 }),
596 "expected span error record carrying the catch error, span records: {span_records:?}"
597 );
598 let original = record_field(&captured, "do_try catch block failed", "original_error")
599 .unwrap_or_else(|| {
600 panic!(
601 "envelope record missing original_error field under active span, captured: {captured:?}"
602 )
603 });
604 assert!(
605 original.contains("orig"),
606 "original_error must carry the original error under an active span, got: {original}"
607 );
608 }
609
610 #[test]
611 fn catch_body_failure_emits_envelope_log() {
612 let mut seg = catch_fails_segment();
613 let (result, captured, _span_records) = capture_debugs_with_span_records(|| {
614 tokio::runtime::Builder::new_current_thread()
615 .enable_all()
616 .build()
617 .expect("current-thread runtime")
618 .block_on(async { seg.run(Exchange::default()).await })
619 });
620
621 assert!(
622 matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
623 "catch failure must surface the catch error as Failed(Io), got: {result:?}"
624 );
625 let original = record_field(&captured, "do_try catch block failed", "original_error")
628 .unwrap_or_else(|| {
629 panic!("envelope record missing original_error field, captured: {captured:?}")
630 });
631 let catch = record_field(&captured, "do_try catch block failed", "catch_error")
632 .unwrap_or_else(|| {
633 panic!("envelope record missing catch_error field, captured: {captured:?}")
634 });
635 assert!(
636 original.contains("orig"),
637 "original_error must carry the original error, got: {original}"
638 );
639 assert!(
640 catch.contains("catch-fail"),
641 "catch_error must carry the catch error, got: {catch}"
642 );
643 }
644
645 use camel_api::{ExpressionErrorClass, FilterPredicate, PredicateSource};
648
649 fn expression_failed() -> CamelError {
650 CamelError::ExpressionFailed {
651 language: "rhai".to_string(),
652 route_id: "r1".to_string(),
653 step_id: "step#0".to_string(),
654 verb: "on_when".to_string(),
655 class: ExpressionErrorClass::Runtime,
656 position: None,
657 conversion: None,
658 cause: None,
659 }
660 }
661
662 fn async_err_predicate(err: CamelError) -> PredicateSource {
663 PredicateSource::Async(Arc::new(move |_: &Exchange| {
664 let err = err.clone();
665 Box::pin(async move { Err(err) }) as camel_api::BoxBoolFuture
666 }))
667 }
668
669 #[tokio::test]
670 async fn finally_on_when_predicate_error_fails_segment() {
671 let finally_call = Arc::new(AtomicU32::new(0));
672 let mut seg = DoTrySegment {
673 try_body: seg_complete(),
674 catches: vec![],
675 finally: Some(FinallyClauseSegment {
676 on_when: Some(async_err_predicate(expression_failed())),
677 body: seg_record(finally_call.clone()),
678 }),
679 };
680
681 let result = seg.run(Exchange::default()).await;
682 match result {
683 PipelineOutcome::Failed(CamelError::ExpressionFailed { .. }) => {}
684 other => panic!("expected Failed(ExpressionFailed), got {other:?}"),
685 }
686 assert_eq!(
687 finally_call.load(Ordering::SeqCst),
688 0,
689 "finally body must not run when its on_when predicate errors"
690 );
691 }
692
693 #[tokio::test]
699 async fn no_clause_matches_runs_finally_and_propagates_original() {
700 let catch_call = Arc::new(AtomicU32::new(0));
701 let finally_call = Arc::new(AtomicU32::new(0));
702
703 let mut seg = DoTrySegment {
704 try_body: seg_fail(CamelError::EndpointCreationFailed(
705 "direct endpoint 'direct:does-not-exist' not registered".into(),
706 )),
707 catches: vec![CatchClauseSegment {
708 matcher: CatchMatcher::ByVariant(vec!["Io".into()]),
709 on_when: None,
710 body: seg_record(catch_call.clone()),
711 disposition: ExceptionDisposition::Handled,
712 }],
713 finally: Some(FinallyClauseSegment {
714 on_when: None,
715 body: seg_record(finally_call.clone()),
716 }),
717 };
718
719 let result = seg.run(Exchange::default()).await;
720 match result {
721 PipelineOutcome::Failed(CamelError::EndpointCreationFailed(msg)) => {
722 assert!(
723 msg.contains("does-not-exist"),
724 "propagated error must be the original try error, got: {msg}"
725 );
726 }
727 other => {
728 panic!("no catch matched: expected Failed(EndpointCreationFailed), got {other:?}")
729 }
730 }
731 assert_eq!(
732 finally_call.load(Ordering::SeqCst),
733 1,
734 "finally must run exactly once when no catch matches"
735 );
736 assert_eq!(
737 catch_call.load(Ordering::SeqCst),
738 0,
739 "the unmatched catch body must not run"
740 );
741 }
742 use camel_language_api::{EvalMeta, LanguageError, to_expression_failed};
745
746 fn catch_variant(names: &[&str], body: camel_api::OutcomeSegment) -> CatchClauseSegment {
747 catch_variant_with(names, body, ExceptionDisposition::Handled)
748 }
749
750 fn catch_variant_with(
751 names: &[&str],
752 body: camel_api::OutcomeSegment,
753 disposition: ExceptionDisposition,
754 ) -> CatchClauseSegment {
755 CatchClauseSegment {
756 matcher: CatchMatcher::ByVariant(names.iter().map(|n| (*n).to_string()).collect()),
757 on_when: None,
758 body,
759 disposition,
760 }
761 }
762
763 fn finally_seg(body: camel_api::OutcomeSegment) -> FinallyClauseSegment {
764 FinallyClauseSegment {
765 on_when: None,
766 body,
767 }
768 }
769
770 #[tokio::test]
771 async fn happy_path_try_succeeds_finally_runs() {
772 let finally_flag = Arc::new(AtomicU32::new(0));
773 let mut seg = DoTrySegment {
774 try_body: seg_complete(),
775 catches: vec![],
776 finally: Some(finally_seg(seg_record(finally_flag.clone()))),
777 };
778
779 let result = seg.run(Exchange::default()).await;
780 assert!(
781 matches!(result, PipelineOutcome::Completed(_)),
782 "try success must complete, got {result:?}"
783 );
784 assert_eq!(finally_flag.load(Ordering::SeqCst), 1);
785 }
786
787 #[tokio::test]
788 async fn catch_by_variant_handled_returns_ok() {
789 let mut seg = DoTrySegment {
790 try_body: seg_fail(CamelError::ProcessorError("boom".into())),
791 catches: vec![catch_variant(&["ProcessorError"], seg_complete())],
792 finally: None,
793 };
794
795 let result = seg.run(Exchange::default()).await;
796 assert!(
797 matches!(result, PipelineOutcome::Completed(_)),
798 "Handled must return Completed, got {result:?}"
799 );
800 }
801
802 #[tokio::test]
803 async fn catch_by_variant_propagate_runs_side_effects_and_rethrows() {
804 let side_effect = Arc::new(AtomicU32::new(0));
805 let mut seg = DoTrySegment {
806 try_body: seg_fail(CamelError::ProcessorError("boom".into())),
807 catches: vec![catch_variant_with(
808 &["ProcessorError"],
809 seg_record(side_effect.clone()),
810 ExceptionDisposition::Propagate,
811 )],
812 finally: None,
813 };
814
815 let result = seg.run(Exchange::default()).await;
816 assert!(
817 matches!(
818 result,
819 PipelineOutcome::Failed(CamelError::ProcessorError(_))
820 ),
821 "Propagate must rethrow the original error, got {result:?}"
822 );
823 assert_eq!(
824 side_effect.load(Ordering::SeqCst),
825 1,
826 "catch branch must have run for side-effects"
827 );
828 }
829
830 #[tokio::test]
831 async fn catch_by_predicate_matches_via_exception_kind() {
832 let predicate = FilterPredicate::new(|ex: &Exchange| {
833 ex.properties
834 .get(camel_api::exchange::PROPERTY_EXCEPTION_KIND)
835 .map(|v| matches!(v, camel_api::Value::String(s) if s == "io"))
836 .unwrap_or(false)
837 });
838 let mut seg = DoTrySegment {
839 try_body: seg_fail(CamelError::Io("disk full".into())),
840 catches: vec![CatchClauseSegment {
841 matcher: CatchMatcher::Predicate(PredicateSource::Sync(predicate)),
842 on_when: None,
843 body: seg_complete(),
844 disposition: ExceptionDisposition::Handled,
845 }],
846 finally: None,
847 };
848
849 let result = seg.run(Exchange::default()).await;
850 assert!(
851 matches!(result, PipelineOutcome::Completed(_)),
852 "Predicate matcher must catch the error and Handled must return Completed, got {result:?}"
853 );
854 }
855
856 #[tokio::test]
857 async fn first_match_wins_subsequent_clauses_not_evaluated() {
858 let first_call = Arc::new(AtomicU32::new(0));
859 let second_call = Arc::new(AtomicU32::new(0));
860 let mut seg = DoTrySegment {
861 try_body: seg_fail(CamelError::Io("err".into())),
862 catches: vec![
863 catch_variant(&["Io"], seg_record(first_call.clone())),
864 catch_variant(&["*"], seg_record(second_call.clone())),
865 ],
866 finally: None,
867 };
868
869 let _ = seg.run(Exchange::default()).await;
870 assert_eq!(first_call.load(Ordering::SeqCst), 1);
871 assert_eq!(second_call.load(Ordering::SeqCst), 0);
872 }
873
874 #[tokio::test]
875 async fn catch_branch_throws_new_error_wins() {
876 let mut seg = DoTrySegment {
877 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
878 catches: vec![catch_variant(
879 &["ProcessorError"],
880 seg_fail(CamelError::Io("catch-fail".into())),
881 )],
882 finally: None,
883 };
884
885 let result = seg.run(Exchange::default()).await;
886 assert!(
887 matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
888 "catch error must win over the try error, got {result:?}"
889 );
890 }
891
892 #[tokio::test]
893 async fn finally_throws_with_no_previous_error_propagates_finally_error() {
894 let mut seg = DoTrySegment {
895 try_body: seg_complete(),
896 catches: vec![],
897 finally: Some(finally_seg(seg_fail(CamelError::Config("fin".into())))),
898 };
899
900 let result = seg.run(Exchange::default()).await;
901 assert!(
902 matches!(result, PipelineOutcome::Failed(CamelError::Config(_))),
903 "finally error must propagate when no previous error exists, got {result:?}"
904 );
905 }
906
907 #[tokio::test]
908 async fn finally_throws_with_previous_error_restores_previous() {
909 let mut seg = DoTrySegment {
910 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
911 catches: vec![],
912 finally: Some(finally_seg(seg_fail(CamelError::Config("fin".into())))),
913 };
914
915 let result = seg.run(Exchange::default()).await;
916 assert!(
917 matches!(
918 result,
919 PipelineOutcome::Failed(CamelError::ProcessorError(_))
920 ),
921 "previous error must be restored when finally throws (Camel parity), got {result:?}"
922 );
923 }
924
925 #[tokio::test]
926 async fn stale_handled_marker_cleared_on_entry() {
927 let mut ex = Exchange::default();
928 ex.set_property(
929 camel_api::exchange::PROPERTY_EXCEPTION_HANDLED,
930 camel_api::Value::Bool(true),
931 );
932 ex.set_error(CamelError::ProcessorError("stale".into()));
933 let mut seg = DoTrySegment {
934 try_body: seg_complete(),
935 catches: vec![],
936 finally: None,
937 };
938
939 let result = seg.run(ex).await;
940 match result {
941 PipelineOutcome::Completed(ex) => assert!(
942 !ex.properties
943 .contains_key(camel_api::exchange::PROPERTY_EXCEPTION_HANDLED),
944 "stale CamelExceptionHandled must be cleared on entry"
945 ),
946 other => panic!("expected Completed, got {other:?}"),
947 }
948 }
949
950 #[tokio::test]
951 async fn nested_do_try_inner_catch_does_not_leak_to_outer() {
952 let inner = DoTrySegment {
953 try_body: seg_fail(CamelError::Io("inner".into())),
954 catches: vec![catch_variant(&["Io"], seg_complete())],
955 finally: None,
956 };
957 let mut outer = DoTrySegment {
958 try_body: camel_api::OutcomeSegment::new(Box::new(inner)),
959 catches: vec![catch_variant(&["Io"], seg_complete())],
960 finally: None,
961 };
962
963 let result = outer.run(Exchange::default()).await;
964 assert!(
965 matches!(result, PipelineOutcome::Completed(_)),
966 "outer must see Completed because inner handled its own error, got {result:?}"
967 );
968 }
969
970 #[tokio::test]
971 async fn catch_all_only_fires_when_no_specific_clause_matches() {
972 let processor_call = Arc::new(AtomicU32::new(0));
973 let catch_all_call = Arc::new(AtomicU32::new(0));
974 let mut seg = DoTrySegment {
975 try_body: seg_fail(CamelError::Io("err".into())),
976 catches: vec![
977 catch_variant(&["ProcessorError"], seg_record(processor_call.clone())),
978 catch_variant(&["*"], seg_record(catch_all_call.clone())),
979 ],
980 finally: None,
981 };
982
983 let _ = seg.run(Exchange::default()).await;
984 assert_eq!(
985 processor_call.load(Ordering::SeqCst),
986 0,
987 "specific ProcessorError clause must not fire on Io error"
988 );
989 assert_eq!(
990 catch_all_call.load(Ordering::SeqCst),
991 1,
992 "catch-all clause must fire when no specific clause matches"
993 );
994 }
995
996 #[tokio::test]
997 async fn catch_throws_skips_finally_and_propagates_catch_err() {
998 let finally_flag = Arc::new(AtomicU32::new(0));
999 let mut seg = DoTrySegment {
1000 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1001 catches: vec![catch_variant(
1002 &["ProcessorError"],
1003 seg_fail(CamelError::Io("catch-fail".into())),
1004 )],
1005 finally: Some(finally_seg(seg_record(finally_flag.clone()))),
1006 };
1007
1008 let result = seg.run(Exchange::default()).await;
1009 assert!(
1010 matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
1011 "catch_err must propagate (not original ProcessorError), got {result:?}"
1012 );
1013 assert_eq!(
1014 finally_flag.load(Ordering::SeqCst),
1015 0,
1016 "compiled segment skips finally after a failed catch body (ADR-0025 invariant #4)"
1017 );
1018 }
1019
1020 #[tokio::test]
1021 async fn catch_throws_and_throwing_finally_still_returns_catch_err() {
1022 let mut seg = DoTrySegment {
1023 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1024 catches: vec![catch_variant(
1025 &["ProcessorError"],
1026 seg_fail(CamelError::Io("catch-fail".into())),
1027 )],
1028 finally: Some(finally_seg(seg_fail(CamelError::Config("fin-fail".into())))),
1029 };
1030
1031 let result = seg.run(Exchange::default()).await;
1032 assert!(
1033 matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
1034 "catch_err (Io) must be returned over finally_err (Config), got {result:?}"
1035 );
1036 }
1037
1038 #[tokio::test]
1039 async fn finally_on_when_false_with_previous_error_still_propagates_original() {
1040 let finally_flag = Arc::new(AtomicU32::new(0));
1041 let mut seg = DoTrySegment {
1042 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1043 catches: vec![],
1044 finally: Some(FinallyClauseSegment {
1045 on_when: Some(PredicateSource::Sync(FilterPredicate::new(|_ex| false))),
1046 body: seg_record(finally_flag.clone()),
1047 }),
1048 };
1049
1050 let result = seg.run(Exchange::default()).await;
1051 assert!(
1052 matches!(
1053 result,
1054 PipelineOutcome::Failed(CamelError::ProcessorError(_))
1055 ),
1056 "original error must propagate even when finally_on_when skips finally, got {result:?}"
1057 );
1058 assert_eq!(
1059 finally_flag.load(Ordering::SeqCst),
1060 0,
1061 "doFinally must NOT run when on_when returns false"
1062 );
1063 }
1064
1065 #[tokio::test]
1066 async fn catch_throws_under_propagate_disposition_returns_catch_err() {
1067 let mut seg = DoTrySegment {
1068 try_body: seg_fail(CamelError::ProcessorError("orig".into())),
1069 catches: vec![catch_variant_with(
1070 &["ProcessorError"],
1071 seg_fail(CamelError::Io("catch-fail".into())),
1072 ExceptionDisposition::Propagate,
1073 )],
1074 finally: None,
1075 };
1076
1077 let result = seg.run(Exchange::default()).await;
1078 assert!(
1079 matches!(result, PipelineOutcome::Failed(CamelError::Io(_))),
1080 "Propagate-disposition catch failure must surface the catch error, got {result:?}"
1081 );
1082 }
1083
1084 #[tokio::test]
1085 async fn catch_when_predicate_error_chains_original() {
1086 let mut seg = DoTrySegment {
1087 try_body: seg_fail(CamelError::ProcessorError("boom".into())),
1088 catches: vec![CatchClauseSegment {
1089 matcher: CatchMatcher::Predicate(async_err_predicate(expression_failed())),
1090 on_when: None,
1091 body: seg_complete(),
1092 disposition: ExceptionDisposition::Handled,
1093 }],
1094 finally: None,
1095 };
1096
1097 let result = seg.run(Exchange::default()).await;
1098 match result {
1099 PipelineOutcome::Failed(CamelError::ExpressionFailed {
1100 cause: Some(cause), ..
1101 }) => {
1102 assert!(
1103 cause.to_string().contains("boom"),
1104 "cause must carry the original error, got: {cause}"
1105 );
1106 }
1107 other => panic!("expected Failed(ExpressionFailed) with chained cause, got {other:?}"),
1108 }
1109 }
1110
1111 #[tokio::test]
1112 async fn catch_on_when_predicate_error_chains_original() {
1113 let mut seg = DoTrySegment {
1114 try_body: seg_fail(CamelError::ProcessorError("boom".into())),
1115 catches: vec![CatchClauseSegment {
1116 matcher: CatchMatcher::ByVariant(vec!["*".into()]),
1117 on_when: Some(async_err_predicate(expression_failed())),
1118 body: seg_complete(),
1119 disposition: ExceptionDisposition::Handled,
1120 }],
1121 finally: None,
1122 };
1123
1124 let result = seg.run(Exchange::default()).await;
1125 match result {
1126 PipelineOutcome::Failed(CamelError::ExpressionFailed {
1127 cause: Some(cause), ..
1128 }) => {
1129 assert!(
1130 cause.to_string().contains("boom"),
1131 "cause must carry the original error, got: {cause}"
1132 );
1133 }
1134 other => panic!("expected Failed(ExpressionFailed) with chained cause, got {other:?}"),
1135 }
1136 }
1137
1138 fn caller_diagnostic_failed(cause: CamelError) -> CamelError {
1141 CamelError::ExpressionFailed {
1142 language: "rhai".to_string(),
1143 route_id: "r1".to_string(),
1144 step_id: "s1".to_string(),
1145 verb: "when".to_string(),
1146 class: ExpressionErrorClass::Runtime,
1147 position: None,
1148 conversion: None,
1149 cause: Some(Box::new(cause)),
1150 }
1151 }
1152
1153 #[test]
1154 fn chain_replaces_mapper_generated_cause() {
1155 let meta = EvalMeta {
1156 language: "xpath".into(),
1157 route_id: "r1".into(),
1158 step_id: "s1".into(),
1159 verb: "when".into(),
1160 target: None,
1161 };
1162 let mapper_err = to_expression_failed(
1163 LanguageError::EvalFailure {
1164 class: ExpressionErrorClass::Parse,
1165 position: None,
1166 detail: Some("body is not valid XML".into()),
1167 },
1168 &meta,
1169 );
1170
1171 let result = chain_predicate_error(mapper_err, CamelError::ProcessorError("boom".into()));
1172
1173 match result {
1174 CamelError::ExpressionFailed {
1175 cause: Some(cause), ..
1176 } => {
1177 assert!(
1178 cause.to_string().contains("boom"),
1179 "mapper diagnostic cause must be replaced by the original, got: {cause}"
1180 );
1181 }
1182 other => panic!("expected ExpressionFailed with chained cause, got {other:?}"),
1183 }
1184 }
1185
1186 #[test]
1187 fn chain_preserves_caller_cause() {
1188 let predicate_err =
1189 caller_diagnostic_failed(CamelError::ProcessorError("caller diagnostic".into()));
1190
1191 let result =
1192 chain_predicate_error(predicate_err, CamelError::ProcessorError("boom".into()));
1193
1194 match result {
1195 CamelError::ExpressionFailed {
1196 cause: Some(cause), ..
1197 } => {
1198 let rendered = cause.to_string();
1199 assert!(
1200 rendered.contains("caller diagnostic"),
1201 "caller cause must pass through untouched, got: {rendered}"
1202 );
1203 assert!(
1204 !rendered.contains("boom"),
1205 "caller cause must not be replaced, got: {rendered}"
1206 );
1207 }
1208 other => panic!("expected ExpressionFailed with caller cause, got {other:?}"),
1209 }
1210 }
1211
1212 #[test]
1213 fn chain_preserves_caller_source_bearing_cause() {
1214 let predicate_err = caller_diagnostic_failed(CamelError::ProcessorErrorWithSource(
1215 "caller diagnostic".into(),
1216 Arc::new(std::io::Error::other("caller source")),
1217 ));
1218
1219 let result =
1220 chain_predicate_error(predicate_err, CamelError::ProcessorError("boom".into()));
1221
1222 match result {
1223 CamelError::ExpressionFailed {
1224 cause: Some(cause), ..
1225 } => {
1226 let rendered = cause.to_string();
1227 assert!(
1228 rendered.contains("caller diagnostic"),
1229 "caller cause must pass through untouched, got: {rendered}"
1230 );
1231 let source = std::error::Error::source(cause.as_ref())
1232 .unwrap_or_else(|| panic!("caller source must survive, cause: {cause:?}"));
1233 assert!(
1234 source.to_string().contains("caller source"),
1235 "caller source must survive untouched, got: {source}"
1236 );
1237 }
1238 other => panic!("expected ExpressionFailed with caller cause, got {other:?}"),
1239 }
1240 }
1241}