Skip to main content

camel_processor/
circuit_breaker.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::{Arc, Mutex};
4use std::task::{Context, Poll};
5use std::time::Instant;
6
7use tower::{Layer, Service};
8
9use camel_api::metrics::MetricsCollector;
10use camel_api::{BoxProcessor, CamelError, CircuitBreakerConfig, Exchange};
11
12// ── State ──────────────────────────────────────────────────────────────
13
14enum CircuitState {
15    Closed {
16        consecutive_failures: u32,
17    },
18    Open {
19        opened_at: Instant,
20    },
21    /// `probe_admitted == true` means a probe request is in flight; subsequent
22    /// concurrent callers must be rejected until the probe completes.
23    /// `probe_admitted == false` means no probe is in flight and the next
24    /// caller is admitted as the probe.
25    HalfOpen {
26        probe_admitted: bool,
27    },
28}
29
30// ── Layer ──────────────────────────────────────────────────────────────
31
32/// Tower Layer that wraps an inner service with circuit-breaker logic.
33#[derive(Clone)]
34pub struct CircuitBreakerLayer {
35    config: CircuitBreakerConfig,
36    state: Arc<Mutex<CircuitState>>,
37    route_id: Arc<str>,
38    metrics: Option<Arc<dyn MetricsCollector>>,
39}
40
41impl CircuitBreakerLayer {
42    /// `route_id` + `metrics` thread the rejection counter
43    /// (`camel_circuit_breaker_rejections_total{route}`) into the wrapped
44    /// service: every open-breaker fast-fail counts as a rejection, never
45    /// as an error (dashboard-observability D2).
46    pub fn new(
47        config: CircuitBreakerConfig,
48        route_id: Arc<str>,
49        metrics: Option<Arc<dyn MetricsCollector>>,
50    ) -> Self {
51        Self {
52            config,
53            state: Arc::new(Mutex::new(CircuitState::Closed {
54                consecutive_failures: 0,
55            })),
56            route_id,
57            metrics,
58        }
59    }
60}
61
62impl<S> Layer<S> for CircuitBreakerLayer {
63    type Service = CircuitBreakerService<S>;
64
65    fn layer(&self, inner: S) -> Self::Service {
66        CircuitBreakerService {
67            inner,
68            config: self.config.clone(),
69            state: Arc::clone(&self.state),
70            route_id: Arc::clone(&self.route_id),
71            metrics: self.metrics.clone(),
72        }
73    }
74}
75
76// ── Service ────────────────────────────────────────────────────────────
77
78/// Tower Service implementing the circuit-breaker pattern.
79pub struct CircuitBreakerService<S> {
80    inner: S,
81    config: CircuitBreakerConfig,
82    state: Arc<Mutex<CircuitState>>,
83    route_id: Arc<str>,
84    metrics: Option<Arc<dyn MetricsCollector>>,
85}
86
87impl<S: Clone> Clone for CircuitBreakerService<S> {
88    fn clone(&self) -> Self {
89        Self {
90            inner: self.inner.clone(),
91            config: self.config.clone(),
92            state: Arc::clone(&self.state),
93            route_id: Arc::clone(&self.route_id),
94            metrics: self.metrics.clone(),
95        }
96    }
97}
98
99impl<S> CircuitBreakerService<S> {
100    /// Count one open-breaker fast-fail as a circuit-breaker rejection.
101    fn record_rejection(&self) {
102        if let Some(ref metrics) = self.metrics {
103            metrics.increment_circuit_breaker_rejection(&self.route_id);
104        }
105    }
106}
107
108impl<S> Service<Exchange> for CircuitBreakerService<S>
109where
110    S: Service<Exchange, Response = Exchange, Error = CamelError> + Clone + Send + 'static,
111    S::Future: Send,
112{
113    type Response = Exchange;
114    type Error = CamelError;
115    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
116
117    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
118        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
119        match *state {
120            CircuitState::Closed { .. } => {
121                drop(state);
122                self.inner.poll_ready(cx)
123            }
124            CircuitState::Open { opened_at } => {
125                if opened_at.elapsed() >= self.config.open_duration {
126                    tracing::info!("Circuit breaker transitioning from Open to HalfOpen");
127                    *state = CircuitState::HalfOpen {
128                        probe_admitted: true,
129                    };
130                    drop(state);
131                    // If the inner service returns Pending we MUST release the
132                    // probe claim so a re-poll can re-claim it; otherwise the
133                    // breaker wedges until the half-open probe completes.
134                    match self.inner.poll_ready(cx) {
135                        Poll::Ready(result) => Poll::Ready(result),
136                        Poll::Pending => {
137                            let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
138                            if matches!(*st, CircuitState::HalfOpen { .. }) {
139                                *st = CircuitState::HalfOpen {
140                                    probe_admitted: false,
141                                };
142                            }
143                            Poll::Pending
144                        }
145                    }
146                } else if self.config.fallback.is_some() {
147                    Poll::Ready(Ok(()))
148                } else {
149                    self.record_rejection();
150                    Poll::Ready(Err(CamelError::CircuitOpen(
151                        "circuit breaker is open".into(),
152                    )))
153                }
154            }
155            CircuitState::HalfOpen { probe_admitted } => {
156                if probe_admitted {
157                    // A probe is already in flight — reject this concurrent
158                    // caller. Always Err (even with fallback): returning Ok
159                    // here would let a 2nd caller reach call() → inner,
160                    // bypassing the single-probe gate. The caller retries
161                    // when after_result resolves the probe state.
162                    drop(state);
163                    self.record_rejection();
164                    Poll::Ready(Err(CamelError::CircuitOpen(
165                        "circuit breaker is half-open (probe in flight)".into(),
166                    )))
167                } else {
168                    // Claim the probe slot and forward to inner. If inner
169                    // returns Pending, release the claim so re-poll works.
170                    *state = CircuitState::HalfOpen {
171                        probe_admitted: true,
172                    };
173                    drop(state);
174                    match self.inner.poll_ready(cx) {
175                        Poll::Ready(result) => Poll::Ready(result),
176                        Poll::Pending => {
177                            let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
178                            if matches!(*st, CircuitState::HalfOpen { .. }) {
179                                *st = CircuitState::HalfOpen {
180                                    probe_admitted: false,
181                                };
182                            }
183                            Poll::Pending
184                        }
185                    }
186                }
187            }
188        }
189    }
190
191    fn call(&mut self, exchange: Exchange) -> Self::Future {
192        {
193            let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
194            if let CircuitState::Open { opened_at } = *st {
195                if opened_at.elapsed() < self.config.open_duration {
196                    if let Some(mut fallback) = self.config.fallback.clone() {
197                        return Box::pin(async move { fallback.call(exchange).await });
198                    }
199                    self.record_rejection();
200                    return Box::pin(async {
201                        Err(CamelError::CircuitOpen("circuit breaker is open".into()))
202                    });
203                }
204
205                tracing::info!("Circuit breaker transitioning from Open to HalfOpen");
206                *st = CircuitState::HalfOpen {
207                    probe_admitted: true,
208                };
209            }
210            // D-M1: the single-probe gate lives in poll_ready — 2nd callers
211            // get Err there and never reach call(). The probe caller (whose
212            // poll_ready set probe_admitted: true) proceeds here to inner.
213        }
214
215        // Clone inner service (Tower pattern) and state handle.
216        let mut inner = self.inner.clone();
217        let state = Arc::clone(&self.state);
218        let config = self.config.clone();
219
220        // Snapshot the current state before calling (briefly lock).
221        let current_is_half_open = matches!(
222            *state.lock().unwrap_or_else(|e| e.into_inner()),
223            CircuitState::HalfOpen { .. }
224        );
225
226        Box::pin(async move {
227            let result = inner.call(exchange).await;
228
229            // Update state based on result (briefly lock).
230            let mut st = state.lock().unwrap_or_else(|e| e.into_inner());
231            match &result {
232                Ok(_) => {
233                    // Success → reset to Closed.
234                    if current_is_half_open {
235                        tracing::info!("Circuit breaker transitioning from HalfOpen to Closed");
236                    }
237                    *st = CircuitState::Closed {
238                        consecutive_failures: 0,
239                    };
240                }
241                Err(_) => {
242                    if current_is_half_open {
243                        // Half-open failure → reopen circuit.
244                        tracing::warn!(
245                            "Circuit breaker transitioning from HalfOpen to Open (probe failed)"
246                        );
247                        *st = CircuitState::Open {
248                            opened_at: Instant::now(),
249                        };
250                    } else if let CircuitState::Closed {
251                        consecutive_failures,
252                    } = &mut *st
253                    {
254                        *consecutive_failures += 1;
255                        if *consecutive_failures >= config.failure_threshold {
256                            tracing::warn!(
257                                threshold = config.failure_threshold,
258                                "Circuit breaker transitioning from Closed to Open (failure threshold reached)"
259                            );
260                            *st = CircuitState::Open {
261                                opened_at: Instant::now(),
262                            };
263                        }
264                    }
265                }
266            }
267
268            result
269        })
270    }
271}
272
273// ── Gate ──────────────────────────────────────────────────────────────
274
275/// Decision returned by [`CircuitBreakerGate::before_call`].
276pub enum CircuitBreakerDecision {
277    /// Circuit is closed or half-open — proceed with the pipeline call.
278    Allow,
279    /// Circuit is open but a fallback processor is configured.
280    /// Call this processor instead of the main pipeline.
281    Fallback(BoxProcessor),
282    /// Circuit is open with no fallback — reject the call.
283    Reject(CamelError),
284}
285
286/// Reusable circuit-breaker gate with explicit `before_call`/`after_result` API.
287#[derive(Clone)]
288pub struct CircuitBreakerGate {
289    config: CircuitBreakerConfig,
290    state: Arc<Mutex<CircuitState>>,
291    route_id: Arc<str>,
292    metrics: Option<Arc<dyn MetricsCollector>>,
293}
294
295impl CircuitBreakerGate {
296    /// `route_id` + `metrics` mirror [`CircuitBreakerLayer::new`]: the
297    /// RouteChannelService breaker path counts its own fast-fail Reject
298    /// arms on `camel_circuit_breaker_rejections_total{route}`.
299    pub fn new(
300        config: CircuitBreakerConfig,
301        route_id: Arc<str>,
302        metrics: Option<Arc<dyn MetricsCollector>>,
303    ) -> Self {
304        Self {
305            config,
306            state: Arc::new(Mutex::new(CircuitState::Closed {
307                consecutive_failures: 0,
308            })),
309            route_id,
310            metrics,
311        }
312    }
313
314    /// Count one open-breaker fast-fail as a circuit-breaker rejection.
315    fn record_rejection(&self) {
316        if let Some(ref metrics) = self.metrics {
317            metrics.increment_circuit_breaker_rejection(&self.route_id);
318        }
319    }
320
321    pub fn before_call(&self) -> CircuitBreakerDecision {
322        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
323        match *state {
324            CircuitState::Closed { .. } => CircuitBreakerDecision::Allow,
325            CircuitState::Open { opened_at } => {
326                if opened_at.elapsed() >= self.config.open_duration {
327                    tracing::info!("Circuit breaker gate: Open → HalfOpen");
328                    *state = CircuitState::HalfOpen {
329                        probe_admitted: true,
330                    };
331                    CircuitBreakerDecision::Allow
332                } else if let Some(ref fallback) = self.config.fallback {
333                    CircuitBreakerDecision::Fallback(fallback.clone())
334                } else {
335                    self.record_rejection();
336                    CircuitBreakerDecision::Reject(CamelError::CircuitOpen(
337                        "circuit breaker is open".into(),
338                    ))
339                }
340            }
341            CircuitState::HalfOpen { probe_admitted } => {
342                if probe_admitted {
343                    if let Some(ref fallback) = self.config.fallback {
344                        CircuitBreakerDecision::Fallback(fallback.clone())
345                    } else {
346                        self.record_rejection();
347                        CircuitBreakerDecision::Reject(CamelError::CircuitOpen(
348                            "circuit breaker is half-open (probe in flight)".into(),
349                        ))
350                    }
351                } else {
352                    *state = CircuitState::HalfOpen {
353                        probe_admitted: true,
354                    };
355                    CircuitBreakerDecision::Allow
356                }
357            }
358        }
359    }
360
361    pub fn after_result(&self, result: &Result<Exchange, CamelError>) {
362        let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
363        let current_is_half_open = matches!(*st, CircuitState::HalfOpen { .. });
364        match result {
365            Ok(_) => {
366                if current_is_half_open {
367                    tracing::info!("Circuit breaker gate: HalfOpen → Closed");
368                }
369                *st = CircuitState::Closed {
370                    consecutive_failures: 0,
371                };
372            }
373            Err(_) => {
374                if current_is_half_open {
375                    tracing::warn!("Circuit breaker gate: HalfOpen → Open (probe failed)");
376                    *st = CircuitState::Open {
377                        opened_at: Instant::now(),
378                    };
379                } else if let CircuitState::Closed {
380                    consecutive_failures,
381                } = &mut *st
382                {
383                    *consecutive_failures += 1;
384                    if *consecutive_failures >= self.config.failure_threshold {
385                        tracing::warn!(
386                            threshold = self.config.failure_threshold,
387                            "Circuit breaker gate: Closed → Open (failure threshold reached)"
388                        );
389                        *st = CircuitState::Open {
390                            opened_at: Instant::now(),
391                        };
392                    }
393                }
394            }
395        }
396    }
397}
398
399// ── Tests ──────────────────────────────────────────────────────────────
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use camel_api::{BoxProcessor, BoxProcessorExt, Message};
405    use std::sync::atomic::{AtomicU32, Ordering};
406    use std::time::Duration;
407    use tower::ServiceExt;
408
409    fn make_exchange() -> Exchange {
410        Exchange::new(Message::new("test"))
411    }
412
413    fn gate_with(config: CircuitBreakerConfig) -> CircuitBreakerGate {
414        CircuitBreakerGate::new(config, Arc::from("test"), None)
415    }
416
417    fn ok_processor() -> BoxProcessor {
418        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
419    }
420
421    fn failing_processor() -> BoxProcessor {
422        BoxProcessor::from_fn(|_ex| {
423            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
424        })
425    }
426
427    fn fail_n_times(n: u32) -> BoxProcessor {
428        let count = Arc::new(AtomicU32::new(0));
429        BoxProcessor::from_fn(move |ex| {
430            let count = Arc::clone(&count);
431            Box::pin(async move {
432                let c = count.fetch_add(1, Ordering::SeqCst);
433                if c < n {
434                    Err(CamelError::ProcessorError(format!("attempt {c}")))
435                } else {
436                    Ok(ex)
437                }
438            })
439        })
440    }
441
442    fn tag_processor(tag: &'static str) -> BoxProcessor {
443        BoxProcessor::from_fn(move |_ex| {
444            Box::pin(async move {
445                let mut out = make_exchange();
446                out.input.body = tag.to_string().into();
447                Ok(out)
448            })
449        })
450    }
451
452    /// 1. Circuit stays closed on success.
453    #[tokio::test]
454    async fn test_stays_closed_on_success() {
455        let config = CircuitBreakerConfig::new().failure_threshold(3);
456        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
457        let mut svc = layer.layer(ok_processor());
458
459        for _ in 0..5 {
460            let result = svc.ready().await.unwrap().call(make_exchange()).await;
461            assert!(result.is_ok());
462        }
463
464        // State should still be closed with 0 failures.
465        let state = svc.state.lock().unwrap();
466        match *state {
467            CircuitState::Closed {
468                consecutive_failures,
469            } => assert_eq!(consecutive_failures, 0),
470            _ => panic!("expected Closed state"),
471        }
472    }
473
474    /// 2. Circuit opens after failure_threshold consecutive failures.
475    #[tokio::test]
476    async fn test_opens_after_failure_threshold() {
477        let config = CircuitBreakerConfig::new().failure_threshold(3);
478        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
479        let mut svc = layer.layer(failing_processor());
480
481        // Three consecutive failures should open the circuit.
482        for _ in 0..3 {
483            let result = svc.ready().await.unwrap().call(make_exchange()).await;
484            assert!(result.is_err());
485        }
486
487        // The next poll_ready should return CircuitOpen error.
488        let waker = futures::task::noop_waker();
489        let mut cx = Context::from_waker(&waker);
490        let poll = Pin::new(&mut svc).poll_ready(&mut cx);
491        match poll {
492            Poll::Ready(Err(CamelError::CircuitOpen(_))) => {} // expected
493            other => panic!("expected CircuitOpen error, got {other:?}"),
494        }
495    }
496
497    /// 3. Circuit transitions to half-open after open_duration.
498    #[tokio::test]
499    async fn test_transitions_to_half_open_after_duration() {
500        let config = CircuitBreakerConfig::new()
501            .failure_threshold(2)
502            .open_duration(Duration::from_millis(50));
503        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
504        // Use fail_n_times(2) so the first 2 calls fail (opening the circuit),
505        // then the third (half-open probe) succeeds.
506        let mut svc = layer.layer(fail_n_times(2));
507
508        // Trigger 2 failures to open the circuit.
509        for _ in 0..2 {
510            let _ = svc.ready().await.unwrap().call(make_exchange()).await;
511        }
512
513        // Circuit is now open. Wait for open_duration to elapse.
514        tokio::time::sleep(Duration::from_millis(60)).await;
515
516        // poll_ready should transition to HalfOpen and succeed.
517        let result = svc.ready().await.unwrap().call(make_exchange()).await;
518        assert!(result.is_ok(), "half-open probe should succeed");
519
520        // After successful probe, circuit should be back to Closed.
521        let state = svc.state.lock().unwrap();
522        match *state {
523            CircuitState::Closed {
524                consecutive_failures,
525            } => assert_eq!(consecutive_failures, 0),
526            _ => panic!("expected Closed state after successful half-open probe"),
527        }
528    }
529
530    /// 4. Half-open failure reopens circuit.
531    #[tokio::test]
532    async fn test_half_open_failure_reopens() {
533        let config = CircuitBreakerConfig::new()
534            .failure_threshold(2)
535            .open_duration(Duration::from_millis(50));
536        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
537        let mut svc = layer.layer(failing_processor());
538
539        // Trigger 2 failures to open the circuit.
540        for _ in 0..2 {
541            let _ = svc.ready().await.unwrap().call(make_exchange()).await;
542        }
543
544        // Wait for open_duration to elapse, transitioning to HalfOpen.
545        tokio::time::sleep(Duration::from_millis(60)).await;
546
547        // Half-open probe fails → circuit reopens.
548        let result = svc.ready().await.unwrap().call(make_exchange()).await;
549        assert!(result.is_err());
550
551        // Circuit should be open again.
552        let state = svc.state.lock().unwrap();
553        match *state {
554            CircuitState::Open { .. } => {} // expected
555            _ => panic!("expected Open state after half-open failure"),
556        }
557    }
558
559    /// 5. Intermittent failures below threshold don't open circuit.
560    #[tokio::test]
561    async fn test_intermittent_failures_dont_open() {
562        let config = CircuitBreakerConfig::new().failure_threshold(3);
563        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
564
565        // Alternate: fail, fail, success, fail, fail, success
566        // The counter should reset on success, so threshold of 3 is never reached.
567        let call_count = Arc::new(AtomicU32::new(0));
568        let cc = Arc::clone(&call_count);
569        let inner = BoxProcessor::from_fn(move |ex| {
570            let cc = Arc::clone(&cc);
571            Box::pin(async move {
572                let c = cc.fetch_add(1, Ordering::SeqCst);
573                // Pattern: fail, fail, success, fail, fail, success
574                if c % 3 == 2 {
575                    Ok(ex)
576                } else {
577                    Err(CamelError::ProcessorError("intermittent".into()))
578                }
579            })
580        });
581
582        let mut svc = layer.layer(inner);
583
584        for _ in 0..6 {
585            let _ = svc.ready().await.unwrap().call(make_exchange()).await;
586        }
587
588        // Circuit should still be closed because successes reset the counter.
589        let state = svc.state.lock().unwrap();
590        match *state {
591            CircuitState::Closed { .. } => {} // expected
592            _ => panic!("expected circuit to remain Closed"),
593        }
594    }
595
596    #[tokio::test]
597    async fn test_open_uses_fallback_when_configured() {
598        let fallback = tag_processor("fallback");
599        let config = CircuitBreakerConfig::new()
600            .failure_threshold(1)
601            .open_duration(Duration::from_secs(60))
602            .fallback(fallback);
603        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
604        let mut svc = layer.layer(failing_processor());
605
606        let _ = svc.ready().await.unwrap().call(make_exchange()).await;
607        let result = svc
608            .ready()
609            .await
610            .unwrap()
611            .call(make_exchange())
612            .await
613            .unwrap();
614        assert_eq!(result.input.body.as_text(), Some("fallback"));
615    }
616
617    #[tokio::test]
618    async fn test_open_without_fallback_returns_err() {
619        let config = CircuitBreakerConfig::new()
620            .failure_threshold(1)
621            .open_duration(Duration::from_secs(60));
622        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
623        let mut svc = layer.layer(failing_processor());
624
625        let _ = svc.ready().await.unwrap().call(make_exchange()).await;
626        let result = svc.ready().await;
627        assert!(matches!(result, Err(CamelError::CircuitOpen(_))));
628    }
629
630    // ── CircuitBreakerGate tests ──────────────────────────────────────────
631
632    #[test]
633    fn test_cb_gate_before_call_closed_allows() {
634        let gate = gate_with(CircuitBreakerConfig {
635            failure_threshold: 3,
636            open_duration: Duration::from_secs(60),
637            success_threshold: 1,
638            fallback: None,
639        });
640        assert!(matches!(gate.before_call(), CircuitBreakerDecision::Allow));
641    }
642
643    #[test]
644    fn test_cb_gate_records_failures_and_opens() {
645        let gate = gate_with(CircuitBreakerConfig {
646            failure_threshold: 2,
647            open_duration: Duration::from_secs(60),
648            success_threshold: 1,
649            fallback: None,
650        });
651        gate.after_result(&Err(CamelError::ProcessorError("fail".into())));
652        assert!(
653            matches!(gate.before_call(), CircuitBreakerDecision::Allow),
654            "still closed after 1 failure"
655        );
656        gate.after_result(&Err(CamelError::ProcessorError("fail".into())));
657        assert!(
658            matches!(gate.before_call(), CircuitBreakerDecision::Reject(_)),
659            "should be open after 2 failures"
660        );
661    }
662
663    #[tokio::test]
664    async fn test_cb_gate_closes_on_success() {
665        let gate = gate_with(CircuitBreakerConfig {
666            failure_threshold: 1,
667            open_duration: Duration::from_millis(1),
668            success_threshold: 1,
669            fallback: None,
670        });
671        gate.after_result(&Err(CamelError::ProcessorError("fail".into())));
672        assert!(
673            matches!(gate.before_call(), CircuitBreakerDecision::Reject(_)),
674            "should be open"
675        );
676        tokio::time::sleep(Duration::from_millis(10)).await;
677        assert!(
678            matches!(gate.before_call(), CircuitBreakerDecision::Allow),
679            "should transition to half-open"
680        );
681        let ex = Exchange::new(Message::new("test"));
682        gate.after_result(&Ok(ex));
683        assert!(
684            matches!(gate.before_call(), CircuitBreakerDecision::Allow),
685            "should be closed again"
686        );
687    }
688
689    #[tokio::test]
690    async fn test_cb_gate_half_open_failure_reopens() {
691        let gate = gate_with(CircuitBreakerConfig {
692            failure_threshold: 1,
693            open_duration: Duration::from_millis(1),
694            success_threshold: 1,
695            fallback: None,
696        });
697        // Open the circuit
698        gate.after_result(&Err(CamelError::ProcessorError("fail".into())));
699        assert!(
700            matches!(gate.before_call(), CircuitBreakerDecision::Reject(_)),
701            "should be open"
702        );
703
704        // Wait for open_duration to elapse → transitions to HalfOpen
705        tokio::time::sleep(Duration::from_millis(10)).await;
706        assert!(
707            matches!(gate.before_call(), CircuitBreakerDecision::Allow),
708            "should be half-open now"
709        );
710
711        // Probe fails in HalfOpen → should reopen
712        gate.after_result(&Err(CamelError::ProcessorError("probe fail".into())));
713        assert!(
714            matches!(gate.before_call(), CircuitBreakerDecision::Reject(_)),
715            "should be open again after probe failure"
716        );
717    }
718
719    #[test]
720    fn test_cb_gate_open_with_fallback_returns_fallback() {
721        let fallback = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
722        let gate = gate_with(CircuitBreakerConfig {
723            failure_threshold: 1,
724            open_duration: Duration::from_secs(60),
725            success_threshold: 1,
726            fallback: Some(fallback),
727        });
728        gate.after_result(&Err(CamelError::ProcessorError("fail".into())));
729        assert!(
730            matches!(gate.before_call(), CircuitBreakerDecision::Fallback(_)),
731            "should return fallback when open"
732        );
733    }
734
735    #[test]
736    fn test_cb_gate_handled_error_counts_as_success() {
737        let gate = gate_with(CircuitBreakerConfig {
738            failure_threshold: 1,
739            open_duration: Duration::from_secs(60),
740            success_threshold: 1,
741            fallback: None,
742        });
743        let ex = Exchange::new(Message::new("test"));
744        gate.after_result(&Ok(ex));
745        assert!(
746            matches!(gate.before_call(), CircuitBreakerDecision::Allow),
747            "handled error should not trip CB"
748        );
749    }
750
751    // ── D-M1: half-open admits a single probe ─────────────────────────────
752
753    /// Gate path: only the first caller in HalfOpen is admitted as the probe;
754    /// every subsequent caller is rejected.
755    #[test]
756    fn gate_half_open_admits_only_one_probe() {
757        let config = CircuitBreakerConfig {
758            failure_threshold: 1,
759            open_duration: Duration::from_millis(1),
760            success_threshold: 1,
761            fallback: None,
762        };
763        let gate = gate_with(config);
764        // Trip: one failure → Open
765        gate.after_result(&Err::<Exchange, CamelError>(CamelError::CircuitOpen(
766            "boom".into(),
767        )));
768        std::thread::sleep(Duration::from_millis(10)); // past open_duration
769        let d1 = gate.before_call();
770        assert!(
771            matches!(d1, CircuitBreakerDecision::Allow),
772            "first probe must be admitted"
773        );
774        let d2 = gate.before_call();
775        assert!(
776            matches!(d2, CircuitBreakerDecision::Reject(_)),
777            "2nd concurrent caller must be rejected"
778        );
779    }
780
781    /// Service path: only the first `poll_ready` in HalfOpen is admitted as the
782    /// probe; every subsequent `poll_ready` from a cloned service must be
783    /// rejected with `CircuitOpen`.
784    #[tokio::test]
785    async fn service_half_open_admits_only_one_probe() {
786        let config = CircuitBreakerConfig::new()
787            .failure_threshold(1)
788            .open_duration(Duration::from_millis(1));
789        let layer = CircuitBreakerLayer::new(config, Arc::from("test"), None);
790        let mut svc1 = layer.layer(failing_processor());
791        let mut svc2 = svc1.clone();
792
793        // Trip to Open
794        let _ = svc1.ready().await.unwrap().call(make_exchange()).await;
795
796        // Wait past open_duration
797        tokio::time::sleep(Duration::from_millis(10)).await;
798
799        // svc1 poll_ready: admitted as probe → Ready(Ok(()))
800        let waker = futures::task::noop_waker();
801        let mut cx = Context::from_waker(&waker);
802        let p1 = std::pin::Pin::new(&mut svc1).poll_ready(&mut cx);
803        assert!(
804            matches!(p1, Poll::Ready(Ok(()))),
805            "first probe admitted, got {p1:?}"
806        );
807
808        // svc2 poll_ready: must be rejected
809        let p2 = std::pin::Pin::new(&mut svc2).poll_ready(&mut cx);
810        match p2 {
811            Poll::Ready(Err(CamelError::CircuitOpen(_))) => {} // expected
812            other => panic!("expected CircuitOpen error on 2nd probe, got {other:?}"),
813        }
814    }
815}