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