Skip to main content

camel_processor/
error_handler.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4
5use tower::{Layer, Service, ServiceExt};
6
7use camel_api::error_handler::{
8    BoundaryKind, ExceptionDisposition, ExceptionPolicy, HEADER_REDELIVERED,
9    HEADER_REDELIVERY_COUNTER, HEADER_REDELIVERY_MAX_COUNTER, PolicyId, RetryOutcome,
10    StepDisposition,
11};
12use camel_api::{BoxProcessor, CamelError, Exchange, PipelineOutcome, SyncBoxProcessor, Value};
13
14async fn execute_on_steps(
15    original: Exchange,
16    original_err: CamelError,
17    on_steps: &SyncBoxProcessor,
18    disposition: ExceptionDisposition,
19    handler: Option<BoxProcessor>,
20) -> Result<Exchange, CamelError> {
21    let snapshot = original.clone();
22    let mut ex = original;
23    ex.set_error(original_err.clone());
24    let mut pipeline = on_steps.clone_inner();
25    let step_result = async {
26        let svc = pipeline.ready().await?;
27        svc.call(ex).await
28    }
29    .await;
30
31    match step_result {
32        Ok(mut ex) => {
33            if disposition == ExceptionDisposition::Handled {
34                ex.handle_error();
35                Ok(ex)
36            } else {
37                // Propagate or Continued — steps execute for side-effects (e.g. logging) but
38                // the modified exchange is discarded and the original error propagated.
39                Err(original_err)
40            }
41        }
42        Err(_) => {
43            // log-policy: handler-owned
44            tracing::warn!(error = %original_err, "on_steps pipeline failed, falling back to handler/DLC");
45            let mut ex = snapshot;
46            ex.set_error(original_err);
47            send_to_handler(ex, handler).await
48        }
49    }
50}
51
52/// Invoke a processor: readiness check + call, unified into a single Result.
53///
54/// Readiness errors and call errors are both returned as `Err(CamelError)`,
55/// allowing the pipeline's recovery loop to handle them uniformly.
56pub async fn invoke_processor(
57    svc: &mut BoxProcessor,
58    ex: Exchange,
59) -> Result<Exchange, CamelError> {
60    match svc.ready().await {
61        Ok(ready) => ready.call(ex).await,
62        Err(err) => Err(err),
63    }
64}
65
66/// Route-level error handler owning ALL error handling logic.
67///
68/// Single owner of DLC, retry, onException policies. Called from
69/// `RouteChannelService` (boundary errors) and `run_steps` (step errors).
70#[async_trait::async_trait]
71pub trait RouteErrorHandler: Send + Sync {
72    /// Match a policy for the given error. Called once before retry.
73    fn match_policy(&self, err: &CamelError) -> Option<PolicyId>;
74
75    /// Phase 1: Retry the failed step.
76    async fn retry_step(
77        &self,
78        policy: Option<PolicyId>,
79        step: &mut dyn camel_api::error_handler::RetryableStep,
80        original: Exchange,
81        error: CamelError,
82    ) -> RetryOutcome;
83
84    /// Phase 2: Determine step disposition after retry exhaustion.
85    async fn handle_step(
86        &self,
87        policy: Option<PolicyId>,
88        exchange: Exchange,
89        error: CamelError,
90    ) -> Result<StepDisposition, CamelError>;
91
92    /// Handle boundary (infrastructure) errors.
93    async fn handle_boundary(
94        &self,
95        kind: BoundaryKind,
96        exchange: Exchange,
97        error: CamelError,
98    ) -> Result<Exchange, CamelError>;
99}
100
101/// Default implementation of RouteErrorHandler.
102/// Owns DLC producer exclusively. Encapsulates retry/onException/DLC logic.
103///
104/// Uses `SyncBoxProcessor` internally so the handler is `Send + Sync` as required
105/// by the `RouteErrorHandler` trait.
106pub struct DefaultRouteErrorHandler {
107    pub(crate) dlc_producer: Option<SyncBoxProcessor>,
108    pub(crate) policies: Vec<(ExceptionPolicy, Option<SyncBoxProcessor>)>,
109    /// When true, restore the original Message (body and headers, pre-route, pre-mutation)
110    /// before forwarding to the DLC/handler. The original message is stashed by
111    /// `RouteChannelService::call` as the `ORIGINAL_MESSAGE_EXTENSION` extension.
112    pub use_original_message: bool,
113}
114
115impl DefaultRouteErrorHandler {
116    pub fn new(
117        dlc_producer: Option<BoxProcessor>,
118        policies: Vec<(ExceptionPolicy, Option<BoxProcessor>)>,
119    ) -> Self {
120        Self {
121            dlc_producer: dlc_producer.map(SyncBoxProcessor::new),
122            policies: policies
123                .into_iter()
124                .map(|(p, prod)| (p, prod.map(SyncBoxProcessor::new)))
125                .collect(),
126            use_original_message: false,
127        }
128    }
129
130    /// Enable restoration of the original message before forwarding to DLC.
131    pub fn with_use_original_message(mut self, enabled: bool) -> Self {
132        self.use_original_message = enabled;
133        self
134    }
135
136    /// Restore the original message from the `CamelOriginalMessage` extension if
137    /// `use_original_message` is enabled. Called immediately before `send_to_handler`
138    /// dispatch in both `handle_step` and `handle_boundary`.
139    fn restore_original_message_if_enabled(&self, exchange: &mut Exchange) {
140        if self.use_original_message
141            && let Some(orig) =
142                exchange.get_extension::<camel_api::Message>(camel_api::ORIGINAL_MESSAGE_EXTENSION)
143        {
144            exchange.input = orig.clone();
145        }
146    }
147
148    /// Resolve (disposition, producer) for a matched policy.
149    /// Shared by handle_step and handle_boundary.
150    fn resolve_producer(
151        &self,
152        policy: Option<PolicyId>,
153    ) -> (ExceptionDisposition, Option<BoxProcessor>) {
154        match policy {
155            Some(PolicyId(idx)) => match self.policies.get(idx) {
156                Some((p, prod)) => (
157                    p.disposition,
158                    prod.as_ref()
159                        .map(|p| p.clone_inner())
160                        .or_else(|| self.dlc_producer.as_ref().map(|p| p.clone_inner())),
161                ),
162                None => (
163                    ExceptionDisposition::Propagate,
164                    self.dlc_producer.as_ref().map(|p| p.clone_inner()),
165                ),
166            },
167            None => (
168                ExceptionDisposition::Propagate,
169                self.dlc_producer.as_ref().map(|p| p.clone_inner()),
170            ),
171        }
172    }
173}
174
175#[async_trait::async_trait]
176impl RouteErrorHandler for DefaultRouteErrorHandler {
177    fn match_policy(&self, err: &CamelError) -> Option<PolicyId> {
178        self.policies
179            .iter()
180            .position(|(p, _)| (p.matches)(err))
181            .map(PolicyId)
182    }
183
184    async fn retry_step(
185        &self,
186        policy: Option<PolicyId>,
187        step: &mut dyn camel_api::error_handler::RetryableStep,
188        original: Exchange,
189        error: CamelError,
190    ) -> RetryOutcome {
191        let Some(PolicyId(idx)) = policy else {
192            return RetryOutcome::Exhausted {
193                exchange: original,
194                error,
195                policy: None,
196            };
197        };
198        let Some((policy_def, _)) = self.policies.get(idx) else {
199            return RetryOutcome::Exhausted {
200                exchange: original,
201                error,
202                policy,
203            };
204        };
205        let Some(ref backoff) = policy_def.retry else {
206            return RetryOutcome::Exhausted {
207                exchange: original,
208                error,
209                policy,
210            };
211        };
212
213        for attempt in 0..backoff.max_attempts {
214            let delay = backoff.delay_for(attempt);
215            tokio::time::sleep(delay).await;
216
217            let mut ex = original.clone();
218            ex.input.set_header(HEADER_REDELIVERED, Value::Bool(true));
219            ex.input.set_header(
220                HEADER_REDELIVERY_COUNTER,
221                Value::Number((attempt + 1).into()),
222            );
223            ex.input.set_header(
224                HEADER_REDELIVERY_MAX_COUNTER,
225                Value::Number(backoff.max_attempts.into()),
226            );
227
228            match step.invoke(ex).await {
229                PipelineOutcome::Completed(exchange) => {
230                    return RetryOutcome::Recovered(exchange);
231                }
232                PipelineOutcome::Stopped(stopped_ex) => {
233                    return RetryOutcome::Stopped(stopped_ex);
234                }
235                PipelineOutcome::Failed(retry_err) => {
236                    if attempt + 1 == backoff.max_attempts {
237                        let mut final_ex = original;
238                        final_ex
239                            .input
240                            .set_header(HEADER_REDELIVERED, Value::Bool(true));
241                        final_ex.input.set_header(
242                            HEADER_REDELIVERY_COUNTER,
243                            Value::Number(backoff.max_attempts.into()),
244                        );
245                        final_ex.input.set_header(
246                            HEADER_REDELIVERY_MAX_COUNTER,
247                            Value::Number(backoff.max_attempts.into()),
248                        );
249                        return RetryOutcome::Exhausted {
250                            exchange: final_ex,
251                            error: retry_err,
252                            policy,
253                        };
254                    }
255                }
256            }
257        }
258
259        RetryOutcome::Exhausted {
260            exchange: original,
261            error,
262            policy,
263        }
264    }
265
266    async fn handle_step(
267        &self,
268        policy: Option<PolicyId>,
269        mut exchange: Exchange,
270        error: CamelError,
271    ) -> Result<StepDisposition, CamelError> {
272        let (disposition, producer) = self.resolve_producer(policy);
273
274        // rc-fu1of: a non-matching policy with no DLC silently propagates —
275        // the operator sees "handler never ran" with no signal why. Emit a
276        // diagnostic naming the error kind so `kind:` vocabulary gaps are
277        // discoverable. debug! (not warn!): non-matching is an expected,
278        // selective-policy path, and this fires per failed exchange.
279        if policy.is_none() && producer.is_none() {
280            tracing::debug!(
281                kind = %error.variant_name(),
282                "no on_exceptions policy matched and no dead-letter channel configured; propagating error"
283            );
284        }
285
286        // Run on_steps if present (using the SAME policy identified by PolicyId).
287        // Skip on_steps for Propagate disposition to prevent double side-effects:
288        // on_steps results would be discarded (snapshot restored), and the DLC
289        // handler fires next — causing duplicate message production.
290        if !matches!(disposition, ExceptionDisposition::Propagate)
291            && let Some(PolicyId(idx)) = policy
292            && let Some((p, _)) = self.policies.get(idx)
293            && let Some(ref steps) = p.on_steps
294        {
295            let snapshot = exchange.clone();
296            exchange.set_error(error.clone());
297            let mut step_pipeline = steps.clone_inner();
298            let step_result = async {
299                let svc = step_pipeline.ready().await?;
300                svc.call(exchange).await
301            }
302            .await;
303            match step_result {
304                Ok(mut ex) => match disposition {
305                    ExceptionDisposition::Handled => {
306                        ex.handle_error();
307                        return Ok(StepDisposition::Handled(ex));
308                    }
309                    ExceptionDisposition::Continued => {
310                        ex.clear_error();
311                        return Ok(StepDisposition::Continued(ex));
312                    }
313                    // Propagate and any future variant restore the snapshot
314                    // and fall through to the DLC/handler path.
315                    _ => {
316                        exchange = snapshot;
317                    }
318                },
319                Err(_) => {
320                    exchange = snapshot;
321                }
322            }
323        }
324
325        // No on_steps, on_steps failed, or Propagate — forward to DLC/handler.
326        // BIND the returned exchange (must use handler output).
327        self.restore_original_message_if_enabled(&mut exchange);
328        exchange.set_error(error.clone());
329        match send_to_handler(exchange, producer).await {
330            Ok(handler_ex) => match disposition {
331                ExceptionDisposition::Handled => {
332                    let mut ex = handler_ex;
333                    ex.clear_error();
334                    Ok(StepDisposition::Handled(ex))
335                }
336                ExceptionDisposition::Continued => {
337                    let mut ex = handler_ex;
338                    ex.clear_error();
339                    Ok(StepDisposition::Continued(ex))
340                }
341                // Propagate and any future variant forward the error.
342                _ => Ok(StepDisposition::Propagate(error)),
343            },
344            // Dead code by construction: send_to_handler always returns Ok.
345            Err(_) => Ok(StepDisposition::Propagate(error)),
346        }
347    }
348
349    async fn handle_boundary(
350        &self,
351        boundary_kind: BoundaryKind,
352        mut exchange: Exchange,
353        error: CamelError,
354    ) -> Result<Exchange, CamelError> {
355        // Boundary errors: match policy, run on_steps, forward to DLC.
356        // Disposition mapping:
357        //   Handled → clear error, return Ok(exchange)
358        //   Propagate | Continued → forward to DLC, return Ok(exchange_with_error)
359        //   (Continued at boundary = Propagate — no next step to continue to)
360        let policy = self.match_policy(&error);
361        let (disposition, producer) = self.resolve_producer(policy);
362
363        // rc-fu1of: a non-matching policy with no DLC silently propagates —
364        // the operator sees "handler never ran" with no signal why. Emit a
365        // diagnostic naming the error kind so `kind:` vocabulary gaps are
366        // discoverable. debug! (not warn!): non-matching is an expected,
367        // selective-policy path, and this fires per failed exchange.
368        // (Parity with handle_step; boundary adds which gate raised the error.)
369        if policy.is_none() && producer.is_none() {
370            tracing::debug!(
371                boundary = ?boundary_kind,
372                kind = %error.variant_name(),
373                "no on_exceptions policy matched and no dead-letter channel configured; propagating error"
374            );
375        }
376
377        // Run on_steps if present (shared logic with handle_step).
378        // Skip on_steps for Propagate/Continued disposition to prevent double
379        // side-effects: on_steps results would be discarded (snapshot restored),
380        // and the DLC handler fires next — causing duplicate message production.
381        // At boundary level, Continued maps to Propagate semantics.
382        if !matches!(
383            disposition,
384            ExceptionDisposition::Propagate | ExceptionDisposition::Continued
385        ) && let Some(PolicyId(idx)) = policy
386            && let Some((p, _)) = self.policies.get(idx)
387            && let Some(ref steps) = p.on_steps
388        {
389            let snapshot = exchange.clone();
390            exchange.set_error(error.clone());
391            let mut step_pipeline = steps.clone_inner();
392            let step_result = async {
393                let svc = step_pipeline.ready().await?;
394                svc.call(exchange).await
395            }
396            .await;
397            match step_result {
398                Ok(mut ex) => match disposition {
399                    ExceptionDisposition::Handled => {
400                        ex.handle_error();
401                        return Ok(ex);
402                    }
403                    // Propagate | Continued and any future variant restore the snapshot.
404                    _ => {
405                        exchange = snapshot;
406                    }
407                },
408                Err(_) => {
409                    exchange = snapshot;
410                }
411            }
412        }
413
414        // Forward to DLC/handler — BIND returned exchange
415        self.restore_original_message_if_enabled(&mut exchange);
416        exchange.set_error(error.clone());
417        match send_to_handler(exchange, producer).await {
418            Ok(handler_ex) => match disposition {
419                ExceptionDisposition::Handled => {
420                    let mut ex = handler_ex;
421                    ex.clear_error();
422                    Ok(ex)
423                }
424                // Propagate | Continued and any future variant forward the error.
425                _ => {
426                    let mut ex = handler_ex;
427                    ex.set_error(error);
428                    Ok(ex)
429                }
430            },
431            // Dead code by construction: send_to_handler always returns Ok.
432            Err(e) => Err(e),
433        }
434    }
435}
436
437/// Tower Layer that wraps a pipeline with error handling behaviour.
438///
439/// Constructed with already-resolved producers; URI resolution happens in `camel-core`.
440pub struct ErrorHandlerLayer {
441    /// Resolved DLC producer (None = log only).
442    dlc_producer: Option<BoxProcessor>,
443    /// Policies with their resolved `handled_by` producers.
444    policies: Vec<(ExceptionPolicy, Option<BoxProcessor>)>,
445}
446
447impl ErrorHandlerLayer {
448    /// Create the layer with pre-resolved producers.
449    pub fn new(
450        dlc_producer: Option<BoxProcessor>,
451        policies: Vec<(ExceptionPolicy, Option<BoxProcessor>)>,
452    ) -> Self {
453        Self {
454            dlc_producer,
455            policies,
456        }
457    }
458}
459
460impl<S> Layer<S> for ErrorHandlerLayer
461where
462    S: Service<Exchange, Response = Exchange, Error = CamelError> + Send + Clone + 'static,
463    S::Future: Send + 'static,
464{
465    type Service = ErrorHandlerService<S>;
466
467    fn layer(&self, inner: S) -> Self::Service {
468        ErrorHandlerService {
469            inner,
470            dlc_producer: self.dlc_producer.clone(),
471            policies: self
472                .policies
473                .iter()
474                .map(|(p, prod)| (p.clone(), prod.clone()))
475                .collect(),
476        }
477    }
478}
479
480/// Tower Service that absorbs pipeline errors by retrying and/or forwarding to a DLC.
481///
482/// `call` always returns `Ok` — errors are absorbed. The returned exchange will have
483/// `has_error() == true` if the pipeline ultimately failed.
484pub struct ErrorHandlerService<S> {
485    inner: S,
486    dlc_producer: Option<BoxProcessor>,
487    policies: Vec<(ExceptionPolicy, Option<BoxProcessor>)>,
488}
489
490impl<S: Clone> Clone for ErrorHandlerService<S> {
491    fn clone(&self) -> Self {
492        Self {
493            inner: self.inner.clone(),
494            dlc_producer: self.dlc_producer.clone(),
495            policies: self
496                .policies
497                .iter()
498                .map(|(p, prod)| (p.clone(), prod.clone()))
499                .collect(),
500        }
501    }
502}
503
504impl<S> ErrorHandlerService<S>
505where
506    S: Service<Exchange, Response = Exchange, Error = CamelError> + Send + Clone + 'static,
507    S::Future: Send + 'static,
508{
509    /// Create the service directly (used in unit tests; in production use the Layer).
510    pub fn new(
511        inner: S,
512        dlc_producer: Option<BoxProcessor>,
513        policies: Vec<(ExceptionPolicy, Option<BoxProcessor>)>,
514    ) -> Self {
515        Self {
516            inner,
517            dlc_producer,
518            policies,
519        }
520    }
521}
522
523impl<S> Service<Exchange> for ErrorHandlerService<S>
524where
525    S: Service<Exchange, Response = Exchange, Error = CamelError> + Send + Clone + 'static,
526    S::Future: Send + 'static,
527{
528    type Response = Exchange;
529    type Error = CamelError;
530    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
531
532    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
533        // Preserve backpressure (Pending) but never leak readiness errors upward.
534        // Readiness errors are deferred to call(), where they go through the same
535        // retry/onException/DLC path as call() errors. This is safe because call()
536        // re-checks readiness on a fresh inner clone via inner.ready().await.
537        match self.inner.poll_ready(cx) {
538            Poll::Pending => Poll::Pending,
539            Poll::Ready(Err(_)) => Poll::Ready(Ok(())),
540            Poll::Ready(Ok(())) => Poll::Ready(Ok(())),
541        }
542    }
543
544    fn call(&mut self, exchange: Exchange) -> Self::Future {
545        let mut inner = self.inner.clone();
546        let dlc = self.dlc_producer.clone();
547        let policies: Vec<(ExceptionPolicy, Option<BoxProcessor>)> = self
548            .policies
549            .iter()
550            .map(|(p, prod)| (p.clone(), prod.clone()))
551            .collect();
552
553        Box::pin(async move {
554            let original = exchange.clone();
555            let result = match inner.ready().await {
556                Ok(svc) => svc.call(exchange).await,
557                Err(e) => Err(e), // readiness error — enters retry/DLC/onException path below
558            };
559
560            let err = match result {
561                Ok(ex) => return Ok(ex),
562                Err(e) => e,
563            };
564
565            // Find the first matching policy.
566            let matched = policies.into_iter().find(|(p, _)| (p.matches)(&err));
567
568            if let Some((policy, policy_producer)) = matched {
569                // Retry if configured.
570                if let Some(ref backoff) = policy.retry {
571                    for attempt in 0..backoff.max_attempts {
572                        let delay = backoff.delay_for(attempt);
573                        tokio::time::sleep(delay).await;
574
575                        // Set redelivery headers
576                        let mut ex = original.clone();
577                        ex.input.set_header(HEADER_REDELIVERED, Value::Bool(true));
578                        ex.input.set_header(
579                            HEADER_REDELIVERY_COUNTER,
580                            Value::Number((attempt + 1).into()),
581                        );
582                        ex.input.set_header(
583                            HEADER_REDELIVERY_MAX_COUNTER,
584                            Value::Number(backoff.max_attempts.into()),
585                        );
586
587                        let result = match inner.ready().await {
588                            Ok(svc) => svc.call(ex).await,
589                            Err(e) => Err(e), // readiness error — enters retry exhaustion path
590                        };
591                        match result {
592                            Ok(ex) => return Ok(ex),
593                            Err(retry_err) => {
594                                if attempt + 1 == backoff.max_attempts {
595                                    // Retries exhausted — send to handler.
596                                    let mut original = original.clone();
597                                    original
598                                        .input
599                                        .set_header(HEADER_REDELIVERED, Value::Bool(true));
600                                    original.input.set_header(
601                                        HEADER_REDELIVERY_COUNTER,
602                                        Value::Number(backoff.max_attempts.into()),
603                                    );
604                                    original.input.set_header(
605                                        HEADER_REDELIVERY_MAX_COUNTER,
606                                        Value::Number(backoff.max_attempts.into()),
607                                    );
608                                    if let Some(ref steps) = policy.on_steps {
609                                        let handler = policy_producer.clone().or(dlc.clone());
610                                        return execute_on_steps(
611                                            original,
612                                            retry_err,
613                                            steps,
614                                            policy.disposition,
615                                            handler,
616                                        )
617                                        .await;
618                                    }
619                                    original.set_error(retry_err);
620                                    let handler = policy_producer.or(dlc);
621                                    return send_to_handler(original, handler).await;
622                                }
623                            }
624                        }
625                    }
626                }
627                // No retry configured (or 0 attempts) — send to policy handler or DLC.
628                if let Some(ref steps) = policy.on_steps {
629                    let handler = policy_producer.or(dlc);
630                    return execute_on_steps(original, err, steps, policy.disposition, handler)
631                        .await;
632                }
633                let mut ex = original.clone();
634                ex.set_error(err);
635                let handler = policy_producer.or(dlc);
636                send_to_handler(ex, handler).await
637            } else {
638                // No matching policy — forward directly to DLC.
639                let mut ex = original;
640                ex.set_error(err);
641                send_to_handler(ex, dlc).await
642            }
643        })
644    }
645}
646
647async fn send_to_handler(
648    exchange: Exchange,
649    producer: Option<BoxProcessor>,
650) -> Result<Exchange, CamelError> {
651    match producer {
652        None => {
653            // log-policy: system-broken
654            tracing::error!(
655                error = ?exchange.error,
656                "Exchange failed with no error handler configured"
657            );
658            Ok(exchange)
659        }
660        Some(mut prod) => match prod.ready().await {
661            Err(e) => {
662                // log-policy: system-broken
663                tracing::error!("DLC/handler not ready: {e}");
664                Ok(exchange)
665            }
666            Ok(svc) => match svc.call(exchange.clone()).await {
667                Ok(ex) => Ok(ex),
668                Err(e) => {
669                    // log-policy: system-broken
670                    tracing::error!("DLC/handler call failed: {e}");
671                    // Return the original exchange with original error intact.
672                    Ok(exchange)
673                }
674            },
675        },
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use camel_api::{
683        BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message, OutcomePipeline,
684        OutcomeSegment, PipelineOutcome, RetryableStep, SyncBoxProcessor, Value,
685        error_handler::RedeliveryPolicy,
686    };
687    use std::future::Future;
688    use std::pin::Pin;
689    use std::sync::{
690        Arc,
691        atomic::{AtomicU32, Ordering},
692    };
693    use std::time::Duration;
694    use tower::ServiceExt;
695
696    fn make_exchange() -> Exchange {
697        Exchange::new(Message::new("test"))
698    }
699
700    fn failing_processor() -> BoxProcessor {
701        BoxProcessor::from_fn(|_ex| {
702            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
703        })
704    }
705
706    fn ok_processor() -> BoxProcessor {
707        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
708    }
709
710    fn fail_n_times(n: u32) -> BoxProcessor {
711        let count = Arc::new(AtomicU32::new(0));
712        BoxProcessor::from_fn(move |ex| {
713            let count = Arc::clone(&count);
714            Box::pin(async move {
715                let c = count.fetch_add(1, Ordering::SeqCst);
716                if c < n {
717                    Err(CamelError::ProcessorError(format!("attempt {c}")))
718                } else {
719                    Ok(ex)
720                }
721            })
722        })
723    }
724
725    #[tokio::test]
726    async fn test_ok_passthrough() {
727        let svc = ErrorHandlerService::new(ok_processor(), None, vec![]);
728        let result = svc.oneshot(make_exchange()).await;
729        assert!(result.is_ok());
730        assert!(!result.unwrap().has_error());
731    }
732
733    #[tokio::test]
734    async fn test_error_goes_to_dlc() {
735        let received = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
736        let received_clone = Arc::clone(&received);
737        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
738            let r = Arc::clone(&received_clone);
739            Box::pin(async move {
740                r.lock().unwrap().push(ex.clone());
741                Ok(ex)
742            })
743        });
744
745        let svc = ErrorHandlerService::new(failing_processor(), Some(dlc), vec![]);
746        let result = svc.oneshot(make_exchange()).await;
747        assert!(result.is_ok());
748        let ex = result.unwrap();
749        assert!(ex.has_error());
750        assert_eq!(received.lock().unwrap().len(), 1);
751    }
752
753    #[tokio::test]
754    async fn test_retry_recovers() {
755        let inner = fail_n_times(2);
756        let policy = ExceptionPolicy {
757            matches: Arc::new(|_| true),
758            retry: Some(RedeliveryPolicy {
759                max_attempts: 3,
760                initial_delay: Duration::from_millis(1),
761                multiplier: 1.0,
762                max_delay: Duration::from_millis(10),
763                jitter_factor: 0.0,
764            }),
765            handled_by: None,
766            on_steps: None,
767            disposition: ExceptionDisposition::Propagate,
768        };
769        let svc = ErrorHandlerService::new(inner, None, vec![(policy, None)]);
770        let result = svc.oneshot(make_exchange()).await;
771        assert!(result.is_ok());
772        assert!(!result.unwrap().has_error());
773    }
774
775    #[tokio::test]
776    async fn test_retry_exhausted_goes_to_dlc() {
777        let inner = fail_n_times(10);
778        let received = Arc::new(std::sync::Mutex::new(0u32));
779        let received_clone = Arc::clone(&received);
780        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
781            let r = Arc::clone(&received_clone);
782            Box::pin(async move {
783                *r.lock().unwrap() += 1;
784                Ok(ex)
785            })
786        });
787        let policy = ExceptionPolicy {
788            matches: Arc::new(|_| true),
789            retry: Some(RedeliveryPolicy {
790                max_attempts: 2,
791                initial_delay: Duration::from_millis(1),
792                multiplier: 1.0,
793                max_delay: Duration::from_millis(10),
794                jitter_factor: 0.0,
795            }),
796            handled_by: None,
797            on_steps: None,
798            disposition: ExceptionDisposition::Propagate,
799        };
800        let svc = ErrorHandlerService::new(inner, Some(dlc), vec![(policy, None)]);
801        let result = svc.oneshot(make_exchange()).await;
802        assert!(result.is_ok());
803        assert!(result.unwrap().has_error());
804        assert_eq!(*received.lock().unwrap(), 1);
805    }
806
807    #[test]
808    fn test_poll_ready_delegates_to_inner() {
809        use std::sync::atomic::AtomicBool;
810
811        /// A service that returns `Pending` on the first `poll_ready`, then `Ready`.
812        #[derive(Clone)]
813        struct DelayedReadyService {
814            ready: Arc<AtomicBool>,
815        }
816
817        impl Service<Exchange> for DelayedReadyService {
818            type Response = Exchange;
819            type Error = CamelError;
820            type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
821
822            fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
823                if self.ready.fetch_or(true, Ordering::SeqCst) {
824                    // Already marked ready (second+ call) → Ready
825                    Poll::Ready(Ok(()))
826                } else {
827                    // First call → Pending, schedule a wake
828                    cx.waker().wake_by_ref();
829                    Poll::Pending
830                }
831            }
832
833            fn call(&mut self, ex: Exchange) -> Self::Future {
834                Box::pin(async move { Ok(ex) })
835            }
836        }
837
838        let waker = futures::task::noop_waker();
839        let mut cx = Context::from_waker(&waker);
840
841        let inner = DelayedReadyService {
842            ready: Arc::new(AtomicBool::new(false)),
843        };
844        let mut svc = ErrorHandlerService::new(inner, None, vec![]);
845
846        // First poll_ready: inner returns Pending, so ErrorHandlerService must too.
847        let first = Pin::new(&mut svc).poll_ready(&mut cx);
848        assert!(first.is_pending(), "expected Pending on first poll_ready");
849
850        // Second poll_ready: inner returns Ready, so ErrorHandlerService must too.
851        let second = Pin::new(&mut svc).poll_ready(&mut cx);
852        assert!(second.is_ready(), "expected Ready on second poll_ready");
853    }
854
855    #[tokio::test]
856    async fn test_no_matching_policy_uses_dlc() {
857        let received = Arc::new(std::sync::Mutex::new(0u32));
858        let received_clone = Arc::clone(&received);
859        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
860            let r = Arc::clone(&received_clone);
861            Box::pin(async move {
862                *r.lock().unwrap() += 1;
863                Ok(ex)
864            })
865        });
866        let policy = ExceptionPolicy::new(|e| matches!(e, CamelError::Io(_)));
867        let svc = ErrorHandlerService::new(failing_processor(), Some(dlc), vec![(policy, None)]);
868        let result = svc.oneshot(make_exchange()).await;
869        assert!(result.is_ok());
870        assert_eq!(*received.lock().unwrap(), 1);
871    }
872
873    #[tokio::test]
874    async fn test_redelivery_headers_are_set() {
875        use camel_api::error_handler::{
876            HEADER_REDELIVERED, HEADER_REDELIVERY_COUNTER, HEADER_REDELIVERY_MAX_COUNTER,
877            RedeliveryPolicy,
878        };
879
880        let inner = fail_n_times(10);
881        let received = Arc::new(std::sync::Mutex::new(None));
882        let received_clone = Arc::clone(&received);
883        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
884            let r = Arc::clone(&received_clone);
885            Box::pin(async move {
886                *r.lock().unwrap() = Some(ex.clone());
887                Ok(ex)
888            })
889        });
890
891        let policy = ExceptionPolicy {
892            matches: Arc::new(|_| true),
893            retry: Some(RedeliveryPolicy {
894                max_attempts: 2,
895                initial_delay: Duration::from_millis(1),
896                multiplier: 1.0,
897                max_delay: Duration::from_millis(10),
898                jitter_factor: 0.0,
899            }),
900            handled_by: None,
901            on_steps: None,
902            disposition: ExceptionDisposition::Propagate,
903        };
904
905        let svc = ErrorHandlerService::new(inner, Some(dlc), vec![(policy, None)]);
906        let _ = svc.oneshot(make_exchange()).await.unwrap();
907
908        let ex = received.lock().unwrap().take().unwrap();
909        assert_eq!(
910            ex.input.header(HEADER_REDELIVERED),
911            Some(&Value::Bool(true))
912        );
913        assert_eq!(
914            ex.input.header(HEADER_REDELIVERY_COUNTER),
915            Some(&Value::Number(2.into()))
916        );
917        assert_eq!(
918            ex.input.header(HEADER_REDELIVERY_MAX_COUNTER),
919            Some(&Value::Number(2.into()))
920        );
921    }
922
923    #[tokio::test]
924    async fn test_jitter_produces_varying_delays_in_retry_flow() {
925        use std::time::Instant;
926
927        let inner = fail_n_times(10);
928        let received = Arc::new(std::sync::Mutex::new(None));
929        let received_clone = Arc::clone(&received);
930        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
931            let r = Arc::clone(&received_clone);
932            Box::pin(async move {
933                *r.lock().unwrap() = Some(ex.clone());
934                Ok(ex)
935            })
936        });
937
938        let policy = ExceptionPolicy {
939            matches: Arc::new(|_| true),
940            retry: Some(RedeliveryPolicy {
941                max_attempts: 5,
942                initial_delay: Duration::from_millis(20),
943                multiplier: 1.0,
944                max_delay: Duration::from_millis(100),
945                jitter_factor: 0.5,
946            }),
947            handled_by: None,
948            on_steps: None,
949            disposition: ExceptionDisposition::Propagate,
950        };
951
952        let start = Instant::now();
953        let svc = ErrorHandlerService::new(inner, Some(dlc), vec![(policy, None)]);
954        let _ = svc.oneshot(make_exchange()).await.unwrap();
955        let elapsed = start.elapsed();
956
957        assert!(
958            received.lock().unwrap().is_some(),
959            "DLC should have received exchange"
960        );
961
962        assert!(
963            elapsed >= Duration::from_millis(50),
964            "5 retries with 20ms base delay should take at least 50ms (with jitter low bound)"
965        );
966
967        assert!(
968            elapsed <= Duration::from_millis(500),
969            "5 retries with 20ms base delay + 50% jitter should not exceed 500ms"
970        );
971    }
972
973    #[tokio::test]
974    async fn test_on_steps_handled_true_consumes_error() {
975        use tower::ServiceExt;
976
977        let steps_pipeline = BoxProcessor::new(tower::service_fn(|mut ex: Exchange| {
978            ex.input.body = camel_api::Body::Bytes("handled".into());
979            async move { Ok(ex) }
980        }));
981        let policy = ExceptionPolicy {
982            matches: Arc::new(|_| true),
983            retry: None,
984            handled_by: None,
985            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
986            disposition: ExceptionDisposition::Handled,
987        };
988        let inner = tower::service_fn(|_ex: Exchange| async {
989            Err::<Exchange, CamelError>(CamelError::RouteError("fail".to_string()))
990        });
991        let mut svc = ErrorHandlerService::new(inner, None, vec![(policy, None)]);
992        let ex = Exchange::default();
993        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
994        assert!(result.error.is_none(), "handled:true should clear error");
995        assert!(matches!(result.input.body, camel_api::Body::Bytes(_)));
996    }
997
998    #[tokio::test]
999    async fn test_on_steps_handled_false_propagates_error() {
1000        use tower::ServiceExt;
1001
1002        let steps_pipeline = BoxProcessor::new(tower::service_fn(|mut ex: Exchange| {
1003            ex.input.body = camel_api::Body::Bytes("handled".into());
1004            async move { Ok(ex) }
1005        }));
1006        let policy = ExceptionPolicy {
1007            matches: Arc::new(|_| true),
1008            retry: None,
1009            handled_by: None,
1010            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1011            disposition: ExceptionDisposition::Propagate,
1012        };
1013        let inner = tower::service_fn(|_ex: Exchange| async {
1014            Err::<Exchange, CamelError>(CamelError::RouteError("fail".to_string()))
1015        });
1016        let mut svc = ErrorHandlerService::new(inner, None, vec![(policy, None)]);
1017        let ex = Exchange::default();
1018        let result = svc.ready().await.unwrap().call(ex).await;
1019        assert!(result.is_err(), "handled:false should propagate error");
1020    }
1021
1022    // --- Readiness error capture tests ---
1023    //
1024    // ErrorHandlerService must capture readiness errors (poll_ready returning Err)
1025    // and route them through retry/onException/DLC instead of propagating raw.
1026
1027    /// A service whose `poll_ready` always returns `Ready(Err(...))` but whose
1028    /// `call` returns `Ok`. This simulates a permanently-not-ready endpoint.
1029    #[derive(Clone)]
1030    struct ReadinessFailService {
1031        error: CamelError,
1032    }
1033
1034    impl ReadinessFailService {
1035        fn new(error: CamelError) -> Self {
1036            Self { error }
1037        }
1038    }
1039
1040    impl Service<Exchange> for ReadinessFailService {
1041        type Response = Exchange;
1042        type Error = CamelError;
1043        type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1044
1045        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1046            Poll::Ready(Err(self.error.clone()))
1047        }
1048
1049        fn call(&mut self, ex: Exchange) -> Self::Future {
1050            // call() should never be reached if poll_ready returns Err,
1051            // but Tower's ready().await on a clone will re-encounter the readiness error.
1052            Box::pin(async move { Ok(ex) })
1053        }
1054    }
1055
1056    #[tokio::test]
1057    async fn test_readiness_error_goes_to_dlc() {
1058        let readiness_err = CamelError::ProcessorError("readiness-fail".into());
1059        let inner = ReadinessFailService {
1060            error: readiness_err,
1061        };
1062
1063        let received = Arc::new(std::sync::Mutex::new(Vec::<Exchange>::new()));
1064        let received_clone = Arc::clone(&received);
1065        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
1066            let r = Arc::clone(&received_clone);
1067            Box::pin(async move {
1068                r.lock().unwrap().push(ex.clone());
1069                Ok(ex)
1070            })
1071        });
1072
1073        let svc = ErrorHandlerService::new(inner, Some(dlc), vec![]);
1074        let result = svc.oneshot(make_exchange()).await;
1075
1076        // The error must be absorbed (Ok), not propagated raw (Err).
1077        assert!(
1078            result.is_ok(),
1079            "readiness error should be captured and sent to DLC, got: {:?}",
1080            result
1081        );
1082        let ex = result.unwrap();
1083        assert!(ex.has_error(), "exchange should carry the readiness error");
1084        assert_eq!(
1085            received.lock().unwrap().len(),
1086            1,
1087            "DLC should have received the exchange exactly once"
1088        );
1089    }
1090
1091    #[tokio::test]
1092    async fn test_readiness_error_goes_to_matching_policy() {
1093        let readiness_err = CamelError::ProcessorError("readiness-fail".into());
1094        let inner = ReadinessFailService {
1095            error: readiness_err,
1096        };
1097
1098        let steps_pipeline = BoxProcessor::new(tower::service_fn(|mut ex: Exchange| {
1099            ex.input.body = camel_api::Body::Bytes("handled-readiness".into());
1100            async move { Ok(ex) }
1101        }));
1102        let policy = ExceptionPolicy {
1103            matches: Arc::new(|_| true),
1104            retry: None,
1105            handled_by: None,
1106            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1107            disposition: ExceptionDisposition::Handled,
1108        };
1109
1110        let svc = ErrorHandlerService::new(inner, None, vec![(policy, None)]);
1111        let result = svc.oneshot(make_exchange()).await;
1112
1113        // The error must be absorbed and routed to the on_steps handler.
1114        assert!(
1115            result.is_ok(),
1116            "readiness error should be captured by policy, got: {:?}",
1117            result
1118        );
1119        let ex = result.unwrap();
1120        assert!(ex.error.is_none(), "handled:true should clear error");
1121        assert!(
1122            matches!(ex.input.body, camel_api::Body::Bytes(_)),
1123            "on_steps should have modified the body"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_poll_ready_converts_readiness_error_to_ok() {
1129        let readiness_err = CamelError::ProcessorError("readiness-fail".into());
1130        let inner = ReadinessFailService {
1131            error: readiness_err,
1132        };
1133        let mut svc = ErrorHandlerService::new(inner, None, vec![]);
1134
1135        let waker = futures::task::noop_waker();
1136        let mut cx = Context::from_waker(&waker);
1137
1138        // poll_ready must NOT propagate the readiness error — convert to Ok.
1139        let poll = Pin::new(&mut svc).poll_ready(&mut cx);
1140        match poll {
1141            Poll::Ready(Ok(())) => { /* correct */ }
1142            Poll::Ready(Err(e)) => panic!("poll_ready leaked readiness error: {:?}", e),
1143            Poll::Pending => panic!("poll_ready should be Ready for readiness errors"),
1144        }
1145    }
1146
1147    // --- invoke_processor tests ---
1148
1149    #[tokio::test]
1150    async fn test_invoke_processor_returns_ok_on_success() {
1151        let mut svc = ok_processor();
1152        let ex = make_exchange();
1153        let result = invoke_processor(&mut svc, ex).await;
1154        assert!(result.is_ok());
1155    }
1156
1157    #[tokio::test]
1158    async fn test_invoke_processor_captures_readiness_error() {
1159        let mut failing_ready: BoxProcessor = BoxProcessor::new(ReadinessFailService::new(
1160            CamelError::ProcessorError("not ready".into()),
1161        ));
1162        let ex = make_exchange();
1163        let result = invoke_processor(&mut failing_ready, ex).await;
1164        assert!(result.is_err());
1165    }
1166
1167    #[tokio::test]
1168    async fn test_on_steps_handled_true_clears_exception_properties() {
1169        use tower::ServiceExt;
1170
1171        let steps_pipeline = BoxProcessor::new(tower::service_fn(|mut ex: Exchange| {
1172            ex.input.body = camel_api::Body::Bytes("handled".into());
1173            async move { Ok(ex) }
1174        }));
1175        let policy = ExceptionPolicy {
1176            matches: Arc::new(|_| true),
1177            retry: None,
1178            handled_by: None,
1179            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1180            disposition: ExceptionDisposition::Handled,
1181        };
1182        let inner = tower::service_fn(|_ex: Exchange| async {
1183            Err::<Exchange, CamelError>(CamelError::RouteError("fail".to_string()))
1184        });
1185        let mut svc = ErrorHandlerService::new(inner, None, vec![(policy, None)]);
1186        let ex = Exchange::default();
1187        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1188        assert!(result.error.is_none(), "handled:true should clear error");
1189        assert!(
1190            !result
1191                .properties
1192                .contains_key(camel_api::exchange::PROPERTY_EXCEPTION_MESSAGE),
1193            "handled:true should clear exception properties"
1194        );
1195        assert!(
1196            !result
1197                .properties
1198                .contains_key(camel_api::exchange::PROPERTY_EXCEPTION_KIND),
1199            "handled:true should clear exception kind property"
1200        );
1201        assert!(
1202            !result
1203                .properties
1204                .contains_key(camel_api::exchange::PROPERTY_EXCEPTION_CAUGHT),
1205            "handled:true should clear exception caught property"
1206        );
1207    }
1208
1209    // ── DefaultRouteErrorHandler tests ──
1210
1211    #[test]
1212    fn test_match_policy_returns_id_for_matching_error() {
1213        let handler = DefaultRouteErrorHandler::new(
1214            None,
1215            vec![(
1216                ExceptionPolicy::new(|e| matches!(e, CamelError::ProcessorError(_))),
1217                None,
1218            )],
1219        );
1220        let id = handler.match_policy(&CamelError::ProcessorError("test".into()));
1221        assert_eq!(id, Some(PolicyId(0)));
1222    }
1223
1224    #[test]
1225    fn test_match_policy_returns_none_for_unmatched() {
1226        let handler = DefaultRouteErrorHandler::new(None, vec![]);
1227        let id = handler.match_policy(&CamelError::ProcessorError("test".into()));
1228        assert_eq!(id, None);
1229    }
1230
1231    // ── retry_step tests ──
1232
1233    #[tokio::test]
1234    async fn test_retry_step_succeeds_on_second_attempt() {
1235        let mut policy = ExceptionPolicy::new(|_| true);
1236        policy.retry = Some(RedeliveryPolicy::new(3));
1237        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1238        let mut step = fail_n_times(1); // fails once, then succeeds
1239        let ex = make_exchange();
1240        let outcome = handler
1241            .retry_step(
1242                Some(PolicyId(0)),
1243                &mut step,
1244                ex,
1245                CamelError::ProcessorError("attempt 0".into()),
1246            )
1247            .await;
1248        assert!(matches!(outcome, RetryOutcome::Recovered(_)));
1249    }
1250
1251    #[tokio::test]
1252    async fn test_retry_step_exhausted_when_all_fail() {
1253        let mut policy = ExceptionPolicy::new(|_| true);
1254        policy.retry = Some(RedeliveryPolicy::new(3));
1255        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1256        let mut step = failing_processor();
1257        let ex = make_exchange();
1258        let outcome = handler
1259            .retry_step(
1260                Some(PolicyId(0)),
1261                &mut step,
1262                ex,
1263                CamelError::ProcessorError("boom".into()),
1264            )
1265            .await;
1266        assert!(matches!(outcome, RetryOutcome::Exhausted { .. }));
1267    }
1268
1269    #[tokio::test]
1270    async fn test_retry_step_no_policy_returns_exhausted_immediately() {
1271        let handler = DefaultRouteErrorHandler::new(None, vec![]);
1272        let mut step = ok_processor();
1273        let ex = make_exchange();
1274        let outcome = handler
1275            .retry_step(
1276                None,
1277                &mut step,
1278                ex,
1279                CamelError::ProcessorError("boom".into()),
1280            )
1281            .await;
1282        assert!(matches!(
1283            outcome,
1284            RetryOutcome::Exhausted { policy: None, .. }
1285        ));
1286    }
1287
1288    // ── handle_step tests ──
1289
1290    #[tokio::test]
1291    async fn test_handle_step_propagate_sends_to_dlc() {
1292        let dlc = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
1293        let handler = DefaultRouteErrorHandler::new(Some(dlc), vec![]);
1294        let ex = make_exchange();
1295        let result = handler
1296            .handle_step(None, ex, CamelError::ProcessorError("boom".into()))
1297            .await;
1298        assert!(matches!(result, Ok(StepDisposition::Propagate(_))));
1299    }
1300
1301    #[tokio::test]
1302    async fn test_handle_step_handled_uses_handler_output() {
1303        let handler_producer = BoxProcessor::from_fn(|mut ex| {
1304            Box::pin(async move {
1305                ex.input.set_header("processed_by", Value::Bool(true));
1306                Ok(ex)
1307            })
1308        });
1309        let policy = ExceptionPolicy {
1310            matches: std::sync::Arc::new(|_| true),
1311            retry: None,
1312            handled_by: None,
1313            on_steps: None,
1314            disposition: ExceptionDisposition::Handled,
1315        };
1316        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, Some(handler_producer))]);
1317        let mut ex = make_exchange();
1318        ex.set_error(CamelError::ProcessorError("boom".into()));
1319        let result = handler
1320            .handle_step(
1321                Some(PolicyId(0)),
1322                ex,
1323                CamelError::ProcessorError("boom".into()),
1324            )
1325            .await;
1326        match result {
1327            Ok(StepDisposition::Handled(ex)) => {
1328                assert!(!ex.has_error(), "error should be cleared");
1329                assert_eq!(
1330                    ex.input.header("processed_by"),
1331                    Some(&Value::Bool(true)),
1332                    "should use handler's output exchange"
1333                );
1334            }
1335            other => panic!("expected Handled, got {:?}", other.is_ok()),
1336        }
1337    }
1338
1339    #[tokio::test]
1340    async fn test_handle_step_continued_clears_error() {
1341        let policy = ExceptionPolicy {
1342            matches: std::sync::Arc::new(|_| true),
1343            retry: None,
1344            handled_by: None,
1345            on_steps: None,
1346            disposition: ExceptionDisposition::Continued,
1347        };
1348        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1349        let mut ex = make_exchange();
1350        ex.set_error(CamelError::ProcessorError("boom".into()));
1351        let result = handler
1352            .handle_step(
1353                Some(PolicyId(0)),
1354                ex,
1355                CamelError::ProcessorError("boom".into()),
1356            )
1357            .await;
1358        match result {
1359            Ok(StepDisposition::Continued(ex)) => assert!(!ex.has_error()),
1360            other => panic!("expected Continued, got {:?}", other.is_ok()),
1361        }
1362    }
1363
1364    #[tokio::test]
1365    async fn test_handle_step_with_on_steps_handled() {
1366        let steps_pipeline = BoxProcessor::new(tower::service_fn(|mut ex: Exchange| {
1367            ex.input.body = camel_api::Body::Bytes("on_steps_ran".into());
1368            async move { Ok(ex) }
1369        }));
1370        let policy = ExceptionPolicy {
1371            matches: std::sync::Arc::new(|_| true),
1372            retry: None,
1373            handled_by: None,
1374            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1375            disposition: ExceptionDisposition::Handled,
1376        };
1377        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1378        let mut ex = make_exchange();
1379        ex.set_error(CamelError::ProcessorError("boom".into()));
1380        let result = handler
1381            .handle_step(
1382                Some(PolicyId(0)),
1383                ex,
1384                CamelError::ProcessorError("boom".into()),
1385            )
1386            .await;
1387        match result {
1388            Ok(StepDisposition::Handled(ex)) => {
1389                assert!(!ex.has_error(), "error should be cleared");
1390                assert!(
1391                    matches!(ex.input.body, camel_api::Body::Bytes(_)),
1392                    "on_steps should have modified the body"
1393                );
1394            }
1395            other => panic!("expected Handled, got: {}", other.is_ok()),
1396        }
1397    }
1398
1399    #[tokio::test]
1400    async fn test_handle_step_dlc_failure_propagates() {
1401        let failing_dlc = BoxProcessor::from_fn(|_| {
1402            Box::pin(async { Err(CamelError::ProcessorError("dlc broken".into())) })
1403        });
1404        let handler = DefaultRouteErrorHandler::new(Some(failing_dlc), vec![]);
1405        let ex = make_exchange();
1406        let result = handler
1407            .handle_step(None, ex, CamelError::ProcessorError("original".into()))
1408            .await;
1409        assert!(
1410            matches!(result, Ok(StepDisposition::Propagate(_))),
1411            "DLC failure should still return Propagate with original error"
1412        );
1413    }
1414
1415    // ── handle_boundary tests ──
1416
1417    #[tokio::test]
1418    async fn test_handle_boundary_security_error_goes_to_dlc() {
1419        let dlc_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1420        let count_clone = dlc_count.clone();
1421        let dlc = BoxProcessor::from_fn(move |ex| {
1422            let c = count_clone.clone();
1423            Box::pin(async move {
1424                c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1425                Ok(ex)
1426            })
1427        });
1428        let handler = DefaultRouteErrorHandler::new(Some(dlc), vec![]);
1429        let ex = make_exchange();
1430        let result = handler
1431            .handle_boundary(
1432                BoundaryKind::Security,
1433                ex,
1434                CamelError::Unauthorized("denied".into()),
1435            )
1436            .await;
1437        assert!(result.is_ok());
1438        assert_eq!(dlc_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1439    }
1440
1441    #[tokio::test]
1442    async fn test_handle_boundary_handled_clears_error() {
1443        let policy = ExceptionPolicy {
1444            matches: std::sync::Arc::new(|_| true),
1445            retry: None,
1446            handled_by: None,
1447            on_steps: None,
1448            disposition: ExceptionDisposition::Handled,
1449        };
1450        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1451        let ex = make_exchange();
1452        let result = handler
1453            .handle_boundary(
1454                BoundaryKind::Security,
1455                ex,
1456                CamelError::Unauthorized("denied".into()),
1457            )
1458            .await;
1459        assert!(result.is_ok());
1460        assert!(
1461            !result.unwrap().has_error(),
1462            "Handled disposition should clear error"
1463        );
1464    }
1465
1466    #[tokio::test]
1467    async fn test_handle_boundary_propagate_preserves_error() {
1468        let policy = ExceptionPolicy {
1469            matches: std::sync::Arc::new(|_| true),
1470            retry: None,
1471            handled_by: None,
1472            on_steps: None,
1473            disposition: ExceptionDisposition::Propagate,
1474        };
1475        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1476        let ex = make_exchange();
1477        let result = handler
1478            .handle_boundary(
1479                BoundaryKind::CircuitBreaker,
1480                ex,
1481                CamelError::CircuitOpen("open".into()),
1482            )
1483            .await;
1484        assert!(result.is_ok(), "boundary errors always return Ok");
1485        assert!(
1486            result.unwrap().has_error(),
1487            "Propagate disposition should preserve error"
1488        );
1489    }
1490
1491    #[tokio::test]
1492    async fn test_handle_boundary_continued_preserves_error_like_propagate() {
1493        let dlc_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
1494        let count_clone = dlc_count.clone();
1495        let dlc = BoxProcessor::from_fn(move |ex| {
1496            let c = count_clone.clone();
1497            Box::pin(async move {
1498                c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1499                Ok(ex)
1500            })
1501        });
1502        let policy = ExceptionPolicy {
1503            matches: std::sync::Arc::new(|_| true),
1504            retry: None,
1505            handled_by: None,
1506            on_steps: None,
1507            disposition: ExceptionDisposition::Continued,
1508        };
1509        let handler = DefaultRouteErrorHandler::new(Some(dlc), vec![(policy, None)]);
1510        let ex = make_exchange();
1511        let result = handler
1512            .handle_boundary(
1513                BoundaryKind::Security,
1514                ex,
1515                CamelError::Unauthorized("denied".into()),
1516            )
1517            .await;
1518        assert!(result.is_ok(), "boundary errors always return Ok");
1519        assert!(
1520            result.unwrap().has_error(),
1521            "Continued at boundary should preserve error"
1522        );
1523        assert_eq!(
1524            dlc_count.load(std::sync::atomic::Ordering::SeqCst),
1525            1,
1526            "DLC should be called"
1527        );
1528    }
1529
1530    #[tokio::test]
1531    async fn test_handle_boundary_with_on_steps_handled() {
1532        let steps_pipeline = BoxProcessor::new(tower::service_fn(|mut ex: Exchange| {
1533            ex.input.body = camel_api::Body::Bytes("on_steps_ran".into());
1534            async move { Ok(ex) }
1535        }));
1536        let policy = ExceptionPolicy {
1537            matches: std::sync::Arc::new(|_| true),
1538            retry: None,
1539            handled_by: None,
1540            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1541            disposition: ExceptionDisposition::Handled,
1542        };
1543        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1544        let ex = make_exchange();
1545        let result = handler
1546            .handle_boundary(
1547                BoundaryKind::Security,
1548                ex,
1549                CamelError::Unauthorized("denied".into()),
1550            )
1551            .await;
1552        assert!(result.is_ok(), "boundary errors always return Ok");
1553        let ex = result.unwrap();
1554        assert!(!ex.has_error(), "Handled disposition should clear error");
1555        assert!(
1556            matches!(ex.input.body, camel_api::Body::Bytes(_)),
1557            "on_steps should have modified the body"
1558        );
1559    }
1560
1561    #[tokio::test]
1562    async fn retry_step_segment_stop_maps_to_retry_outcome_stopped() {
1563        use std::sync::Arc;
1564        use std::sync::atomic::{AtomicUsize, Ordering};
1565
1566        #[derive(Clone)]
1567        struct StoppingSegment {
1568            n: Arc<AtomicUsize>,
1569        }
1570        impl OutcomePipeline for StoppingSegment {
1571            fn clone_box(&self) -> Box<dyn OutcomePipeline> {
1572                Box::new(self.clone())
1573            }
1574            fn run<'a>(
1575                &'a mut self,
1576                ex: Exchange,
1577            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1578                let n = self.n.clone();
1579                Box::pin(async move {
1580                    n.fetch_add(1, Ordering::SeqCst);
1581                    PipelineOutcome::Stopped(ex)
1582                })
1583            }
1584        }
1585
1586        let call_count = Arc::new(AtomicUsize::new(0));
1587        let seg = OutcomeSegment::new(Box::new(StoppingSegment {
1588            n: call_count.clone(),
1589        }));
1590        let mut retryable: Box<dyn RetryableStep> = Box::new(seg);
1591
1592        let mut policy = ExceptionPolicy::new(|_e: &CamelError| true);
1593        policy.retry = Some(RedeliveryPolicy::new(3));
1594        let handler = DefaultRouteErrorHandler::new(None, vec![(policy, None)]);
1595
1596        let original = Exchange::new(Message::new("retry-me"));
1597        let err = CamelError::ProcessorError("trigger retry".into());
1598        let outcome = handler
1599            .retry_step(Some(PolicyId(0)), retryable.as_mut(), original, err)
1600            .await;
1601
1602        assert!(
1603            matches!(outcome, RetryOutcome::Stopped(_)),
1604            "Segment Stop must map to RetryOutcome::Stopped, got {:?}",
1605            outcome
1606        );
1607        assert_eq!(
1608            call_count.load(Ordering::SeqCst),
1609            1,
1610            "Stop must short-circuit retry — only 1 invoke expected, got {}",
1611            call_count.load(Ordering::SeqCst)
1612        );
1613    }
1614
1615    #[tokio::test]
1616    async fn retry_step_new_signature_works_with_dlc_producer() {
1617        use std::sync::Arc;
1618        use std::sync::atomic::{AtomicUsize, Ordering};
1619
1620        #[derive(Clone)]
1621        struct CountingProducer {
1622            count: Arc<AtomicUsize>,
1623            succeed_on: usize,
1624        }
1625        impl tower::Service<Exchange> for CountingProducer {
1626            type Response = Exchange;
1627            type Error = CamelError;
1628            type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1629            fn poll_ready(
1630                &mut self,
1631                _cx: &mut std::task::Context<'_>,
1632            ) -> std::task::Poll<Result<(), Self::Error>> {
1633                std::task::Poll::Ready(Ok(()))
1634            }
1635            fn call(&mut self, ex: Exchange) -> Self::Future {
1636                let n = self.count.fetch_add(1, Ordering::SeqCst);
1637                let succeed_on = self.succeed_on;
1638                Box::pin(async move {
1639                    if n >= succeed_on {
1640                        Ok(ex)
1641                    } else {
1642                        Err(CamelError::ProcessorError("retry".into()))
1643                    }
1644                })
1645            }
1646        }
1647
1648        let count = Arc::new(AtomicUsize::new(0));
1649        let producer = CountingProducer {
1650            count: count.clone(),
1651            succeed_on: 2,
1652        };
1653        let sync_bp = SyncBoxProcessor::new(BoxProcessor::new(producer));
1654        let bp1 = sync_bp.clone_inner();
1655        let bp2 = sync_bp.clone_inner();
1656        let mut retryable1: Box<dyn RetryableStep> = Box::new(bp1);
1657        let mut retryable2: Box<dyn RetryableStep> = Box::new(bp2);
1658
1659        let ex = Exchange::new(Message::new("dlc"));
1660        let outcome1 = retryable1.invoke(ex.clone()).await;
1661        let outcome2 = retryable2.invoke(ex).await;
1662        assert!(matches!(outcome1, PipelineOutcome::Failed(_)));
1663        assert!(matches!(outcome2, PipelineOutcome::Failed(_)));
1664        assert_eq!(
1665            count.load(Ordering::SeqCst),
1666            2,
1667            "DLC producer must be invoked exactly twice through SyncBoxProcessor"
1668        );
1669        drop(retryable1);
1670        drop(retryable2);
1671        drop(sync_bp);
1672    }
1673
1674    // ── use_original_message tests ──
1675
1676    #[tokio::test]
1677    async fn test_use_original_message_restores_body_before_dlc() {
1678        // Verifies that when the extension is set, handle_step restores the
1679        // original message body before the DLC sees it.
1680        let dlc_received = Arc::new(std::sync::Mutex::new(None::<Exchange>));
1681        let dlc_received_clone = Arc::clone(&dlc_received);
1682        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
1683            let r = Arc::clone(&dlc_received_clone);
1684            Box::pin(async move {
1685                *r.lock().unwrap() = Some(ex.clone());
1686                Ok(ex)
1687            })
1688        });
1689
1690        let mut handler = DefaultRouteErrorHandler::new(Some(dlc), vec![]);
1691        handler.use_original_message = true;
1692
1693        // Build exchange with original body, stash it, then mutate.
1694        let mut ex = make_exchange();
1695        ex.input = Message::new("original-body");
1696
1697        // Simulate RouteChannelService stashing the original message.
1698        let original: Arc<Message> = Arc::new(ex.input.clone());
1699        ex.set_extension(camel_api::ORIGINAL_MESSAGE_EXTENSION, original);
1700
1701        // Mutate the body (simulating a pipeline step that transforms then fails).
1702        ex.input.body = camel_api::Body::Bytes("mutated-body".into());
1703
1704        // Call handle_step — the restore should fire before send_to_handler.
1705        let result = handler
1706            .handle_step(None, ex, CamelError::ProcessorError("boom".into()))
1707            .await;
1708        assert!(matches!(result, Ok(StepDisposition::Propagate(_))));
1709
1710        // The DLC must have received the exchange with the ORIGINAL body.
1711        let received = dlc_received
1712            .lock()
1713            .unwrap()
1714            .take()
1715            .expect("DLC should have been called");
1716        let received_text = match &received.input.body {
1717            camel_api::Body::Text(s) => s.clone(),
1718            camel_api::Body::Bytes(b) => String::from_utf8_lossy(b).to_string(),
1719            camel_api::Body::Json(v) => v.to_string(),
1720            _ => String::new(),
1721        };
1722        assert_eq!(
1723            received_text, "original-body",
1724            "DLC should receive original message body, not mutated version"
1725        );
1726    }
1727
1728    #[tokio::test]
1729    async fn test_use_original_message_handle_boundary_restores_before_dlc() {
1730        let dlc_received = Arc::new(std::sync::Mutex::new(None::<Exchange>));
1731        let dlc_received_clone = Arc::clone(&dlc_received);
1732        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
1733            let r = Arc::clone(&dlc_received_clone);
1734            Box::pin(async move {
1735                *r.lock().unwrap() = Some(ex.clone());
1736                Ok(ex)
1737            })
1738        });
1739
1740        let mut handler = DefaultRouteErrorHandler::new(Some(dlc), vec![]);
1741        handler.use_original_message = true;
1742
1743        let mut ex = make_exchange();
1744        ex.input = Message::new("orig-boundary");
1745
1746        let original: Arc<Message> = Arc::new(ex.input.clone());
1747        ex.set_extension(camel_api::ORIGINAL_MESSAGE_EXTENSION, original);
1748
1749        // Mutate body before boundary error.
1750        ex.input.body = camel_api::Body::Bytes("mutated-boundary".into());
1751
1752        let result = handler
1753            .handle_boundary(
1754                BoundaryKind::Security,
1755                ex,
1756                CamelError::Unauthorized("denied".into()),
1757            )
1758            .await;
1759        assert!(result.is_ok());
1760
1761        let received = dlc_received
1762            .lock()
1763            .unwrap()
1764            .take()
1765            .expect("DLC should have been called");
1766        let received_text = match &received.input.body {
1767            camel_api::Body::Text(s) => s.clone(),
1768            camel_api::Body::Bytes(b) => String::from_utf8_lossy(b).to_string(),
1769            camel_api::Body::Json(v) => v.to_string(),
1770            _ => String::new(),
1771        };
1772        assert_eq!(
1773            received_text, "orig-boundary",
1774            "handle_boundary should restore original message before sending to DLC"
1775        );
1776    }
1777
1778    #[tokio::test]
1779    async fn test_use_original_message_false_does_not_restore() {
1780        // When use_original_message is false (default), the mutation should PASS THROUGH.
1781        let dlc_received = Arc::new(std::sync::Mutex::new(None::<Exchange>));
1782        let dlc_received_clone = Arc::clone(&dlc_received);
1783        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
1784            let r = Arc::clone(&dlc_received_clone);
1785            Box::pin(async move {
1786                *r.lock().unwrap() = Some(ex.clone());
1787                Ok(ex)
1788            })
1789        });
1790
1791        let handler = DefaultRouteErrorHandler::new(Some(dlc), vec![]);
1792        // use_original_message defaults to false
1793
1794        let mut ex = make_exchange();
1795        ex.input = Message::new("original-body");
1796
1797        // Stash still set but flag is false — must be ignored.
1798        let original: Arc<Message> = Arc::new(ex.input.clone());
1799        ex.set_extension(camel_api::ORIGINAL_MESSAGE_EXTENSION, original);
1800
1801        // Mutate the body.
1802        ex.input.body = camel_api::Body::Bytes("mutated-body".into());
1803
1804        let result = handler
1805            .handle_step(None, ex, CamelError::ProcessorError("boom".into()))
1806            .await;
1807        assert!(matches!(result, Ok(StepDisposition::Propagate(_))));
1808
1809        let received = dlc_received
1810            .lock()
1811            .unwrap()
1812            .take()
1813            .expect("DLC should have been called");
1814        let received_text = match &received.input.body {
1815            camel_api::Body::Text(s) => s.clone(),
1816            camel_api::Body::Bytes(b) => String::from_utf8_lossy(b).to_string(),
1817            camel_api::Body::Json(v) => v.to_string(),
1818            _ => String::new(),
1819        };
1820        assert_eq!(
1821            received_text, "mutated-body",
1822            "When use_original_message=false, DLC should see the mutated body"
1823        );
1824    }
1825
1826    // ── D-M17: Propagate must skip on_steps (prevents double side-effects) ──
1827
1828    #[tokio::test]
1829    async fn propagate_skips_on_steps_in_handle_step() {
1830        let on_steps_called = Arc::new(AtomicU32::new(0));
1831        let on_steps_called_clone = on_steps_called.clone();
1832        let steps_pipeline = BoxProcessor::new(tower::service_fn(move |mut ex: Exchange| {
1833            let c = on_steps_called_clone.clone();
1834            Box::pin(async move {
1835                c.fetch_add(1, Ordering::SeqCst);
1836                ex.input.body = camel_api::Body::Bytes("on_steps_ran".into());
1837                Ok(ex)
1838            })
1839        }));
1840        let dlc_called = Arc::new(AtomicU32::new(0));
1841        let dlc_called_clone = dlc_called.clone();
1842        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
1843            let c = dlc_called_clone.clone();
1844            Box::pin(async move {
1845                c.fetch_add(1, Ordering::SeqCst);
1846                Ok(ex)
1847            })
1848        });
1849        let policy = ExceptionPolicy {
1850            matches: std::sync::Arc::new(|_| true),
1851            retry: None,
1852            handled_by: None,
1853            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1854            disposition: ExceptionDisposition::Propagate,
1855        };
1856        let handler = DefaultRouteErrorHandler::new(Some(dlc), vec![(policy, None)]);
1857        let mut ex = make_exchange();
1858        ex.set_error(CamelError::ProcessorError("boom".into()));
1859        let result = handler
1860            .handle_step(
1861                Some(PolicyId(0)),
1862                ex,
1863                CamelError::ProcessorError("boom".into()),
1864            )
1865            .await;
1866        assert!(
1867            matches!(result, Ok(StepDisposition::Propagate(_))),
1868            "Propagate disposition should return Propagate"
1869        );
1870        assert_eq!(
1871            on_steps_called.load(Ordering::SeqCst),
1872            0,
1873            "on_steps must NOT be called when disposition is Propagate (double side-effect bug)"
1874        );
1875        assert_eq!(
1876            dlc_called.load(Ordering::SeqCst),
1877            1,
1878            "DLC should still be called when disposition is Propagate"
1879        );
1880    }
1881
1882    #[tokio::test]
1883    async fn propagate_skips_on_steps_in_handle_boundary() {
1884        let on_steps_called = Arc::new(AtomicU32::new(0));
1885        let on_steps_called_clone = on_steps_called.clone();
1886        let steps_pipeline = BoxProcessor::new(tower::service_fn(move |mut ex: Exchange| {
1887            let c = on_steps_called_clone.clone();
1888            Box::pin(async move {
1889                c.fetch_add(1, Ordering::SeqCst);
1890                ex.input.body = camel_api::Body::Bytes("on_steps_ran".into());
1891                Ok(ex)
1892            })
1893        }));
1894        let dlc_called = Arc::new(AtomicU32::new(0));
1895        let dlc_called_clone = dlc_called.clone();
1896        let dlc = BoxProcessor::from_fn(move |ex: Exchange| {
1897            let c = dlc_called_clone.clone();
1898            Box::pin(async move {
1899                c.fetch_add(1, Ordering::SeqCst);
1900                Ok(ex)
1901            })
1902        });
1903        let policy = ExceptionPolicy {
1904            matches: std::sync::Arc::new(|_| true),
1905            retry: None,
1906            handled_by: None,
1907            on_steps: Some(SyncBoxProcessor::new(steps_pipeline)),
1908            disposition: ExceptionDisposition::Propagate,
1909        };
1910        let handler = DefaultRouteErrorHandler::new(Some(dlc), vec![(policy, None)]);
1911        let ex = make_exchange();
1912        let result = handler
1913            .handle_boundary(
1914                BoundaryKind::CircuitBreaker,
1915                ex,
1916                CamelError::CircuitOpen("open".into()),
1917            )
1918            .await;
1919        assert!(result.is_ok(), "boundary errors always return Ok");
1920        assert!(
1921            result.unwrap().has_error(),
1922            "Propagate disposition should preserve error"
1923        );
1924        assert_eq!(
1925            on_steps_called.load(Ordering::SeqCst),
1926            0,
1927            "on_steps must NOT be called when disposition is Propagate (double side-effect bug)"
1928        );
1929        assert_eq!(
1930            dlc_called.load(Ordering::SeqCst),
1931            1,
1932            "DLC should still be called when disposition is Propagate"
1933        );
1934    }
1935
1936    #[test]
1937    fn test_handle_step_no_match_emits_silent_propagate_diagnostic() {
1938        // rc-xtiem sweep item [1]: parity with the handle_boundary
1939        // diagnostic test — proves the ORIGINAL rc-fu1of site still fires.
1940        let handler = DefaultRouteErrorHandler::new(None, vec![]);
1941        let (result, captured) = capture_debugs(|| {
1942            tokio::runtime::Builder::new_current_thread()
1943                .enable_all()
1944                .build()
1945                .expect("current-thread runtime")
1946                .block_on(handler.handle_step(
1947                    None,
1948                    make_exchange(),
1949                    CamelError::Unauthorized("denied".into()),
1950                ))
1951        });
1952        assert!(result.is_ok(), "step handler always returns Ok");
1953        assert!(
1954            captured.iter().any(|line| {
1955                line.contains(
1956                    "no on_exceptions policy matched and no dead-letter channel configured; \
1957                     propagating error",
1958                )
1959            }),
1960            "expected silent-propagate diagnostic, captured: {captured:?}"
1961        );
1962        assert!(
1963            captured
1964                .iter()
1965                .any(|line| line.contains("kind=Unauthorized")),
1966            "diagnostic should name the error kind, captured: {captured:?}"
1967        );
1968    }
1969
1970    #[test]
1971    fn test_handle_boundary_no_match_emits_silent_propagate_diagnostic() {
1972        let handler = DefaultRouteErrorHandler::new(None, vec![]);
1973        let (result, captured) = capture_debugs(|| {
1974            tokio::runtime::Builder::new_current_thread()
1975                .enable_all()
1976                .build()
1977                .expect("current-thread runtime")
1978                .block_on(handler.handle_boundary(
1979                    BoundaryKind::Security,
1980                    make_exchange(),
1981                    CamelError::Unauthorized("denied".into()),
1982                ))
1983        });
1984        assert!(result.is_ok(), "boundary handler always returns Ok");
1985        assert!(
1986            captured.iter().any(|line| {
1987                line.contains(
1988                    "no on_exceptions policy matched and no dead-letter channel configured; \
1989                     propagating error",
1990                )
1991            }),
1992            "expected silent-propagate diagnostic, captured: {captured:?}"
1993        );
1994        assert!(
1995            captured.iter().any(
1996                |line| line.contains("boundary=Security") && line.contains("kind=Unauthorized")
1997            ),
1998            "diagnostic should name the boundary gate and error kind, captured: {captured:?}"
1999        );
2000    }
2001
2002    /// Test-only log capture: installs a minimal subscriber via
2003    /// `tracing::subscriber::with_default` for the duration of one closure and
2004    /// records DEBUG-level (and above) event fields. No global state — safe
2005    /// under parallel test threads. (Mirror of camel-config's `log_capture`.)
2006    mod log_capture {
2007        use std::fmt;
2008        use std::sync::{Arc, Mutex};
2009        use tracing::field::{Field, Visit};
2010        use tracing::span::{Attributes, Record};
2011        use tracing::{Event, Id, Level, Metadata, Subscriber};
2012
2013        type Sink = Arc<Mutex<Vec<String>>>;
2014
2015        struct Recorder {
2016            events: Sink,
2017            next_span_id: std::sync::atomic::AtomicU64,
2018        }
2019
2020        struct FieldVisitor(String);
2021
2022        impl Visit for FieldVisitor {
2023            fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
2024                if !self.0.is_empty() {
2025                    self.0.push(' ');
2026                }
2027                let _ = fmt::write(&mut self.0, format_args!("{}={:?}", field.name(), value));
2028            }
2029        }
2030
2031        impl Subscriber for Recorder {
2032            fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
2033                true
2034            }
2035
2036            fn new_span(&self, _attrs: &Attributes<'_>) -> Id {
2037                let id = self
2038                    .next_span_id
2039                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2040                    + 1;
2041                Id::from_u64(id)
2042            }
2043
2044            fn record(&self, _span: &Id, _values: &Record<'_>) {}
2045            fn record_follows_from(&self, _span: &Id, _follows_from: &Id) {}
2046
2047            fn event(&self, event: &Event<'_>) {
2048                if *event.metadata().level() >= Level::DEBUG {
2049                    let mut visitor = FieldVisitor(String::new());
2050                    event.record(&mut visitor);
2051                    if let Ok(mut slot) = self.events.lock() {
2052                        slot.push(visitor.0);
2053                    }
2054                }
2055            }
2056
2057            fn enter(&self, _span: &Id) {}
2058            fn exit(&self, _span: &Id) {}
2059        }
2060
2061        /// Runs `f` with a capturing subscriber installed and returns
2062        /// `(f's result, captured event field strings)` in emission order.
2063        /// Rendered as `field="value"` pairs joined by spaces, with the
2064        /// human-readable text under the standard `message` field.
2065        pub(super) fn capture_debugs<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
2066            let sink: Sink = Default::default();
2067            let recorder = Recorder {
2068                events: Arc::clone(&sink),
2069                next_span_id: Default::default(),
2070            };
2071            let out = tracing::subscriber::with_default(recorder, f);
2072            let collected = sink
2073                .lock()
2074                .ok()
2075                .map(|slot| slot.clone())
2076                .unwrap_or_default();
2077            (out, collected)
2078        }
2079    }
2080
2081    use log_capture::capture_debugs;
2082}