Skip to main content

ipfrs_network/
circuit_breaker.rs

1//! Circuit breaker pattern for per-peer fault tolerance.
2//!
3//! Implements the classic Closed / Open / HalfOpen state machine to prevent
4//! cascading failures in P2P networks.  Each peer gets its own independent
5//! circuit breaker tracked by a [`CircuitBreakerRegistry`].
6//!
7//! ## State machine
8//!
9//! ```text
10//!   ┌──────────────────────────────────────────────┐
11//!   │  consecutive_failures >= failure_threshold   │
12//!   ▼                                              │
13//! Closed ──────────────────────────────────► Open  │
14//!   ▲                                        │     │
15//!   │  consecutive_successes >= success_threshold  │
16//!   │                                        │     │
17//!   │   now >= opened_at + timeout_ms        │     │
18//!   └──────────── HalfOpen ◄────────────────┘     │
19//!                    │                             │
20//!                    └─── failure ────────────────►┘
21//! ```
22
23use std::collections::{HashMap, VecDeque};
24
25// ──────────────────────────────────────────────────────────────────────────────
26// CircuitState
27// ──────────────────────────────────────────────────────────────────────────────
28
29/// Internal state of a per-peer circuit breaker.
30///
31/// Timestamps are Unix milliseconds (or any monotonic u64 counter the caller
32/// provides — the implementation never calls the system clock itself).
33#[derive(Clone, Debug, PartialEq)]
34pub enum CircuitBreakerState {
35    /// Normal operation — all calls are allowed through.
36    Closed,
37    /// Circuit tripped — calls are rejected until `opened_at + timeout_ms`.
38    Open {
39        /// Timestamp (ms) at which the circuit was opened.
40        opened_at: u64,
41    },
42    /// Recovery probe phase — a limited number of calls are allowed through.
43    HalfOpen {
44        /// Timestamp (ms) at which the half-open probe phase started.
45        probe_start: u64,
46    },
47}
48
49/// Public alias kept for backward-compatibility with the previous API surface.
50pub type PeerCircuitState = CircuitBreakerState;
51
52// ──────────────────────────────────────────────────────────────────────────────
53// CircuitConfig
54// ──────────────────────────────────────────────────────────────────────────────
55
56/// Configuration knobs for a [`PeerCircuitBreaker`].
57#[derive(Clone, Debug)]
58pub struct CircuitConfig {
59    /// Consecutive failures required to transition from Closed → Open.
60    pub failure_threshold: u32,
61    /// Consecutive successes in HalfOpen required to transition → Closed.
62    pub success_threshold: u32,
63    /// Milliseconds to wait in Open before transitioning to HalfOpen.
64    pub timeout_ms: u64,
65    /// Maximum concurrent in-flight calls allowed while in HalfOpen.
66    pub half_open_max_calls: u32,
67    /// Calls that take longer than this (ms) count as failures even when
68    /// the underlying operation reports success.
69    pub slow_call_threshold_ms: u64,
70    /// Sliding window size: the last `window_size` results are tracked for
71    /// failure-rate calculation.
72    pub window_size: u32,
73}
74
75impl Default for CircuitConfig {
76    fn default() -> Self {
77        Self {
78            failure_threshold: 5,
79            success_threshold: 2,
80            timeout_ms: 30_000,
81            half_open_max_calls: 1,
82            slow_call_threshold_ms: 5_000,
83            window_size: 10,
84        }
85    }
86}
87
88// ──────────────────────────────────────────────────────────────────────────────
89// CallResult
90// ──────────────────────────────────────────────────────────────────────────────
91
92/// Outcome of a single call attempt that the caller feeds back to the circuit
93/// breaker via [`PeerCircuitBreaker::record_result`].
94#[derive(Clone, Debug)]
95pub enum CallResult {
96    /// The call completed successfully.
97    Success {
98        /// Wall-clock duration of the call in milliseconds.
99        duration_ms: u64,
100    },
101    /// The call returned an application-level error.
102    Failure {
103        /// Wall-clock duration of the call in milliseconds.
104        duration_ms: u64,
105        /// Human-readable description of the error.
106        reason: String,
107    },
108    /// The call was cancelled or timed out before a response was received.
109    Timeout {
110        /// How long the call was in-flight before being aborted.
111        duration_ms: u64,
112    },
113}
114
115impl CallResult {
116    /// Returns `true` if this result should be treated as a *failure* by the
117    /// circuit breaker.
118    ///
119    /// A `Success` is treated as a failure when its `duration_ms` meets or
120    /// exceeds `slow_call_threshold_ms`.
121    pub fn is_failure(&self, slow_call_threshold_ms: u64) -> bool {
122        match self {
123            Self::Failure { .. } | Self::Timeout { .. } => true,
124            Self::Success { duration_ms } => *duration_ms >= slow_call_threshold_ms,
125        }
126    }
127}
128
129// ──────────────────────────────────────────────────────────────────────────────
130// CircuitStats
131// ──────────────────────────────────────────────────────────────────────────────
132
133/// Snapshot of statistics for a single peer's circuit breaker.
134#[derive(Clone, Debug, Default)]
135pub struct CircuitStats {
136    /// Human-readable current state: `"Closed"`, `"Open"`, or `"HalfOpen"`.
137    pub state: String,
138    /// Number of failures recorded in the sliding window.
139    pub failure_count: u32,
140    /// Number of successes recorded in the sliding window.
141    pub success_count: u32,
142    /// Current run of consecutive failures (resets on any success).
143    pub consecutive_failures: u32,
144    /// Current run of consecutive successes (only meaningful in HalfOpen).
145    pub consecutive_successes: u32,
146    /// Total calls ever attempted (including rejected ones).
147    pub total_calls: u64,
148    /// Calls rejected because the circuit was Open (or HalfOpen at capacity).
149    pub rejected_calls: u64,
150    /// Timestamp (ms) of the most recent Open transition.
151    pub last_opened_at: Option<u64>,
152    /// Timestamp (ms) of the most recent Closed transition (recovery).
153    pub last_closed_at: Option<u64>,
154}
155
156// ──────────────────────────────────────────────────────────────────────────────
157// PeerCircuitBreaker
158// ──────────────────────────────────────────────────────────────────────────────
159
160/// Per-peer circuit breaker implementing the Closed / Open / HalfOpen state
161/// machine.
162///
163/// The caller is responsible for providing monotonically increasing timestamps
164/// (`now: u64`) — typically Unix milliseconds — so the implementation remains
165/// fully testable without touching the system clock.
166#[derive(Clone, Debug)]
167pub struct PeerCircuitBreaker {
168    /// Identifier of the peer this circuit is guarding.
169    pub peer_id: String,
170    /// Configuration.
171    pub config: CircuitConfig,
172    /// Current state of the circuit.
173    pub state: CircuitBreakerState,
174    /// Sliding window of the last `config.window_size` results.
175    /// `true` = success, `false` = failure.
176    pub window: VecDeque<bool>,
177    /// Consecutive failures in the current Closed stretch.
178    pub consecutive_failures: u32,
179    /// Consecutive successes in the current HalfOpen stretch.
180    pub consecutive_successes: u32,
181    /// Total calls attempted (including rejected ones).
182    pub total_calls: u64,
183    /// Calls rejected (circuit Open or HalfOpen at capacity).
184    pub rejected_calls: u64,
185    /// Timestamp of the most recent Open transition.
186    pub last_opened_at: Option<u64>,
187    /// Timestamp of the most recent Closed (recovery) transition.
188    pub last_closed_at: Option<u64>,
189    /// Number of in-flight calls currently allowed in HalfOpen.
190    pub half_open_calls: u32,
191}
192
193impl PeerCircuitBreaker {
194    /// Create a new circuit breaker for `peer_id` with the supplied config.
195    pub fn new(peer_id: String, config: CircuitConfig) -> Self {
196        Self {
197            peer_id,
198            config,
199            state: CircuitBreakerState::Closed,
200            window: VecDeque::new(),
201            consecutive_failures: 0,
202            consecutive_successes: 0,
203            total_calls: 0,
204            rejected_calls: 0,
205            last_opened_at: None,
206            last_closed_at: None,
207            half_open_calls: 0,
208        }
209    }
210
211    // ── State query ──────────────────────────────────────────────────────────
212
213    /// Returns `true` if a call should be allowed right now.
214    ///
215    /// Side-effect: if the circuit is Open and the timeout has elapsed, it is
216    /// silently transitioned to HalfOpen before returning `true`.
217    pub fn can_call(&mut self, now: u64) -> bool {
218        match &self.state.clone() {
219            CircuitBreakerState::Closed => true,
220            CircuitBreakerState::Open { opened_at } => {
221                if now >= opened_at + self.config.timeout_ms {
222                    // Transition Open → HalfOpen and allow this call.
223                    self.state = CircuitBreakerState::HalfOpen { probe_start: now };
224                    self.half_open_calls = 0;
225                    self.consecutive_successes = 0;
226                    true
227                } else {
228                    false
229                }
230            }
231            CircuitBreakerState::HalfOpen { .. } => {
232                self.half_open_calls < self.config.half_open_max_calls
233            }
234        }
235    }
236
237    // ── Recording results ────────────────────────────────────────────────────
238
239    /// Feed back the outcome of a call.
240    ///
241    /// Updates the sliding window, counters, and triggers state transitions.
242    pub fn record_result(&mut self, result: CallResult, now: u64) {
243        let is_failure = result.is_failure(self.config.slow_call_threshold_ms);
244        self.push_window(!is_failure);
245
246        match self.state.clone() {
247            CircuitBreakerState::Closed => {
248                self.record_closed(is_failure, now);
249            }
250            CircuitBreakerState::HalfOpen { .. } => {
251                self.record_half_open(is_failure, now);
252            }
253            CircuitBreakerState::Open { .. } => {
254                // Calls should not reach here in normal flow (can_call returns
255                // false while Open), but we handle it gracefully.
256            }
257        }
258    }
259
260    /// Process a result while the circuit is Closed.
261    fn record_closed(&mut self, is_failure: bool, now: u64) {
262        if is_failure {
263            self.consecutive_failures += 1;
264            self.consecutive_successes = 0;
265            if self.consecutive_failures >= self.config.failure_threshold {
266                self.trip_open(now);
267            }
268        } else {
269            self.consecutive_failures = 0;
270            self.consecutive_successes += 1;
271        }
272    }
273
274    /// Process a result while the circuit is HalfOpen.
275    fn record_half_open(&mut self, is_failure: bool, now: u64) {
276        if is_failure {
277            // Any failure in HalfOpen immediately re-trips the circuit.
278            self.consecutive_successes = 0;
279            self.trip_open(now);
280        } else {
281            self.consecutive_successes += 1;
282            self.consecutive_failures = 0;
283            if self.consecutive_successes >= self.config.success_threshold {
284                self.close(now);
285            }
286        }
287    }
288
289    /// Transition to Open state and record the timestamp.
290    fn trip_open(&mut self, now: u64) {
291        self.state = CircuitBreakerState::Open { opened_at: now };
292        self.last_opened_at = Some(now);
293        self.half_open_calls = 0;
294    }
295
296    /// Transition to Closed state and clear counters.
297    fn close(&mut self, now: u64) {
298        self.state = CircuitBreakerState::Closed;
299        self.last_closed_at = Some(now);
300        self.consecutive_failures = 0;
301        self.consecutive_successes = 0;
302        self.half_open_calls = 0;
303    }
304
305    // ── Sliding window helpers ───────────────────────────────────────────────
306
307    /// Push a result into the sliding window, evicting the oldest entry when
308    /// the window is full.
309    fn push_window(&mut self, success: bool) {
310        if self.window.len() >= self.config.window_size as usize {
311            self.window.pop_front();
312        }
313        self.window.push_back(success);
314    }
315
316    // ── Metrics ─────────────────────────────────────────────────────────────
317
318    /// Fraction of failures in the current sliding window.
319    ///
320    /// Returns `0.0` when the window is empty.
321    pub fn failure_rate(&self) -> f64 {
322        if self.window.is_empty() {
323            return 0.0;
324        }
325        let failures = self.window.iter().filter(|&&s| !s).count();
326        failures as f64 / self.window.len() as f64
327    }
328
329    /// Number of successes in the current sliding window.
330    fn window_success_count(&self) -> u32 {
331        self.window.iter().filter(|&&s| s).count() as u32
332    }
333
334    /// Number of failures in the current sliding window.
335    fn window_failure_count(&self) -> u32 {
336        self.window.iter().filter(|&&s| !s).count() as u32
337    }
338
339    /// Force the circuit to Closed and reset all counters.
340    pub fn reset(&mut self, now: u64) {
341        self.state = CircuitBreakerState::Closed;
342        self.window.clear();
343        self.consecutive_failures = 0;
344        self.consecutive_successes = 0;
345        self.half_open_calls = 0;
346        self.last_closed_at = Some(now);
347    }
348
349    /// Return a snapshot of current statistics.
350    pub fn stats(&self) -> CircuitStats {
351        let state_str = match &self.state {
352            CircuitBreakerState::Closed => "Closed",
353            CircuitBreakerState::Open { .. } => "Open",
354            CircuitBreakerState::HalfOpen { .. } => "HalfOpen",
355        };
356        CircuitStats {
357            state: state_str.to_string(),
358            failure_count: self.window_failure_count(),
359            success_count: self.window_success_count(),
360            consecutive_failures: self.consecutive_failures,
361            consecutive_successes: self.consecutive_successes,
362            total_calls: self.total_calls,
363            rejected_calls: self.rejected_calls,
364            last_opened_at: self.last_opened_at,
365            last_closed_at: self.last_closed_at,
366        }
367    }
368
369    /// Convenience: return `true` if the circuit is currently Closed.
370    pub fn is_closed(&self) -> bool {
371        matches!(self.state, CircuitBreakerState::Closed)
372    }
373
374    /// Convenience: return `true` if the circuit is currently Open.
375    pub fn is_open(&self) -> bool {
376        matches!(self.state, CircuitBreakerState::Open { .. })
377    }
378
379    /// Convenience: return `true` if the circuit is currently HalfOpen.
380    pub fn is_half_open(&self) -> bool {
381        matches!(self.state, CircuitBreakerState::HalfOpen { .. })
382    }
383}
384
385// ──────────────────────────────────────────────────────────────────────────────
386// RegistryStats
387// ──────────────────────────────────────────────────────────────────────────────
388
389/// Aggregate statistics across all peers in a [`CircuitBreakerRegistry`].
390#[derive(Clone, Debug, Default)]
391pub struct RegistryStats {
392    /// Total number of tracked peers.
393    pub total_peers: usize,
394    /// Peers currently in Closed state.
395    pub closed_count: usize,
396    /// Peers currently in Open state.
397    pub open_count: usize,
398    /// Peers currently in HalfOpen state.
399    pub half_open_count: usize,
400    /// Sum of rejected calls across all peers.
401    pub total_rejected_calls: u64,
402}
403
404// ──────────────────────────────────────────────────────────────────────────────
405// PeerCircuit — backward-compatible thin wrapper
406// ──────────────────────────────────────────────────────────────────────────────
407
408/// Backward-compatible public struct that wraps the per-peer circuit data.
409///
410/// New code should prefer [`PeerCircuitBreaker`] directly.
411#[derive(Clone, Debug)]
412pub struct PeerCircuit {
413    /// Peer identifier.
414    pub peer_id: String,
415    /// Current state of the circuit.
416    pub state: CircuitBreakerState,
417    /// Consecutive failures accumulated while Closed.
418    pub consecutive_failures: u32,
419    /// Consecutive successes accumulated while HalfOpen.
420    pub probe_successes: u32,
421    /// Accumulated totals.
422    pub total_calls: u64,
423    /// Rejected call count.
424    pub rejected_calls: u64,
425}
426
427impl From<&PeerCircuitBreaker> for PeerCircuit {
428    fn from(b: &PeerCircuitBreaker) -> Self {
429        Self {
430            peer_id: b.peer_id.clone(),
431            state: b.state.clone(),
432            consecutive_failures: b.consecutive_failures,
433            probe_successes: b.consecutive_successes,
434            total_calls: b.total_calls,
435            rejected_calls: b.rejected_calls,
436        }
437    }
438}
439
440// ──────────────────────────────────────────────────────────────────────────────
441// CircuitBreakerRegistry
442// ──────────────────────────────────────────────────────────────────────────────
443
444/// Registry that manages one [`PeerCircuitBreaker`] per peer.
445///
446/// All mutating methods take `now: u64` (caller-supplied timestamp in ms) so
447/// the registry is fully testable without touching the system clock.
448pub struct CircuitBreakerRegistry {
449    breakers: HashMap<String, PeerCircuitBreaker>,
450    default_config: CircuitConfig,
451}
452
453impl CircuitBreakerRegistry {
454    /// Create an empty registry with the supplied default configuration.
455    pub fn new(default_config: CircuitConfig) -> Self {
456        Self {
457            breakers: HashMap::new(),
458            default_config,
459        }
460    }
461
462    /// Return a mutable reference to the breaker for `peer_id`, creating one
463    /// with the default configuration if it does not exist yet.
464    pub fn get_or_create(&mut self, peer_id: &str) -> &mut PeerCircuitBreaker {
465        let config = self.default_config.clone();
466        self.breakers
467            .entry(peer_id.to_string())
468            .or_insert_with(|| PeerCircuitBreaker::new(peer_id.to_string(), config))
469    }
470
471    /// Return `true` if a call to `peer_id` is currently permitted.
472    ///
473    /// If the peer has no breaker yet it is implicitly Closed.
474    pub fn can_call(&mut self, peer_id: &str, now: u64) -> bool {
475        self.get_or_create(peer_id).can_call(now)
476    }
477
478    /// Record the outcome of a call to `peer_id`.
479    pub fn record_result(&mut self, peer_id: &str, result: CallResult, now: u64) {
480        let breaker = self.get_or_create(peer_id);
481        breaker.total_calls += 1;
482        breaker.record_result(result, now);
483    }
484
485    /// IDs of peers whose circuit is currently Open (still blocking calls).
486    pub fn open_peers(&mut self, now: u64) -> Vec<String> {
487        self.breakers
488            .iter_mut()
489            .filter_map(|(id, b)| {
490                if let CircuitBreakerState::Open { opened_at } = &b.state {
491                    // Only return if the circuit is *still* open (timeout not yet elapsed).
492                    if now < opened_at + b.config.timeout_ms {
493                        return Some(id.clone());
494                    }
495                }
496                None
497            })
498            .collect()
499    }
500
501    /// IDs of peers whose circuit is currently HalfOpen.
502    pub fn half_open_peers(&self) -> Vec<String> {
503        self.breakers
504            .iter()
505            .filter_map(|(id, b)| {
506                if matches!(b.state, CircuitBreakerState::HalfOpen { .. }) {
507                    Some(id.clone())
508                } else {
509                    None
510                }
511            })
512            .collect()
513    }
514
515    /// Force the circuit for `peer_id` to Closed.
516    ///
517    /// Returns `false` if no breaker exists for the peer.
518    pub fn reset_peer(&mut self, peer_id: &str, now: u64) -> bool {
519        match self.breakers.get_mut(peer_id) {
520            Some(b) => {
521                b.reset(now);
522                true
523            }
524            None => false,
525        }
526    }
527
528    /// Remove Closed peers that have fewer than `min_calls` total calls.
529    ///
530    /// Returns the number of peers evicted.
531    pub fn evict_closed_peers(&mut self, min_calls: u64) -> usize {
532        let before = self.breakers.len();
533        self.breakers.retain(|_, b| {
534            // Keep if: not Closed, OR has enough calls to be worth retaining.
535            !matches!(b.state, CircuitBreakerState::Closed) || b.total_calls >= min_calls
536        });
537        before - self.breakers.len()
538    }
539
540    /// Aggregate statistics for the entire registry.
541    pub fn registry_stats(&mut self, now: u64) -> RegistryStats {
542        let mut stats = RegistryStats {
543            total_peers: self.breakers.len(),
544            ..Default::default()
545        };
546        for b in self.breakers.values_mut() {
547            // Trigger any pending Open → HalfOpen transitions so counts are accurate.
548            if let CircuitBreakerState::Open { opened_at } = &b.state {
549                if now >= opened_at + b.config.timeout_ms {
550                    let probe_start = now;
551                    b.state = CircuitBreakerState::HalfOpen { probe_start };
552                    b.half_open_calls = 0;
553                    b.consecutive_successes = 0;
554                }
555            }
556            match &b.state {
557                CircuitBreakerState::Closed => stats.closed_count += 1,
558                CircuitBreakerState::Open { .. } => stats.open_count += 1,
559                CircuitBreakerState::HalfOpen { .. } => stats.half_open_count += 1,
560            }
561            stats.total_rejected_calls += b.rejected_calls;
562        }
563        stats
564    }
565
566    /// Number of peers currently tracked.
567    pub fn len(&self) -> usize {
568        self.breakers.len()
569    }
570
571    /// Returns `true` when no peers are tracked.
572    pub fn is_empty(&self) -> bool {
573        self.breakers.is_empty()
574    }
575}
576
577// ──────────────────────────────────────────────────────────────────────────────
578// Tests
579// ──────────────────────────────────────────────────────────────────────────────
580
581#[cfg(test)]
582mod tests {
583    use crate::circuit_breaker::{
584        CallResult, CircuitBreakerRegistry, CircuitConfig, PeerCircuitBreaker, RegistryStats,
585    };
586
587    // ── helpers ──────────────────────────────────────────────────────────────
588
589    fn default_config() -> CircuitConfig {
590        CircuitConfig::default()
591    }
592
593    fn make_breaker(peer_id: &str) -> PeerCircuitBreaker {
594        PeerCircuitBreaker::new(peer_id.to_string(), default_config())
595    }
596
597    fn success(ms: u64) -> CallResult {
598        CallResult::Success { duration_ms: ms }
599    }
600
601    fn failure(ms: u64) -> CallResult {
602        CallResult::Failure {
603            duration_ms: ms,
604            reason: "err".to_string(),
605        }
606    }
607
608    fn timeout_result(ms: u64) -> CallResult {
609        CallResult::Timeout { duration_ms: ms }
610    }
611
612    /// Push `n` failures into `b` at timestamp `now`.
613    fn inject_failures(b: &mut PeerCircuitBreaker, n: u32, now: u64) {
614        for _ in 0..n {
615            b.record_result(failure(1), now);
616        }
617    }
618
619    /// Trip the breaker to Open by injecting `failure_threshold` failures.
620    fn trip_open(b: &mut PeerCircuitBreaker, now: u64) {
621        inject_failures(b, b.config.failure_threshold, now);
622    }
623
624    // ── 1. New breaker is Closed ──────────────────────────────────────────────
625
626    #[test]
627    fn new_breaker_is_closed() {
628        let b = make_breaker("p1");
629        assert!(b.is_closed());
630    }
631
632    // ── 2. can_call returns true while Closed ─────────────────────────────────
633
634    #[test]
635    fn can_call_while_closed() {
636        let mut b = make_breaker("p2");
637        assert!(b.can_call(0));
638    }
639
640    // ── 3. Consecutive failures trip the circuit Open ─────────────────────────
641
642    #[test]
643    fn consecutive_failures_trip_open() {
644        let mut b = make_breaker("p3");
645        inject_failures(&mut b, 4, 0); // one below threshold
646        assert!(b.is_closed(), "should still be Closed after 4 failures");
647        b.record_result(failure(1), 0); // 5th failure
648        assert!(
649            b.is_open(),
650            "should be Open after hitting failure_threshold"
651        );
652    }
653
654    // ── 4. Open circuit rejects can_call ─────────────────────────────────────
655
656    #[test]
657    fn open_circuit_rejects_can_call() {
658        let mut b = make_breaker("p4");
659        trip_open(&mut b, 0);
660        assert!(!b.can_call(1000), "should be rejected while Open");
661    }
662
663    // ── 5. Open transitions to HalfOpen after timeout ─────────────────────────
664
665    #[test]
666    fn open_transitions_to_half_open_after_timeout() {
667        let mut b = make_breaker("p5");
668        trip_open(&mut b, 0);
669        // timeout_ms default = 30_000
670        let result = b.can_call(30_000);
671        assert!(result, "should allow call after timeout elapses");
672        assert!(b.is_half_open(), "should be HalfOpen");
673    }
674
675    // ── 6. Open stays Open before timeout ────────────────────────────────────
676
677    #[test]
678    fn open_stays_open_before_timeout() {
679        let mut b = make_breaker("p6");
680        trip_open(&mut b, 0);
681        assert!(
682            !b.can_call(29_999),
683            "should still be blocked before timeout"
684        );
685        assert!(b.is_open());
686    }
687
688    // ── 7. HalfOpen allows limited calls ─────────────────────────────────────
689
690    #[test]
691    fn half_open_allows_limited_calls() {
692        let mut b = PeerCircuitBreaker::new(
693            "p7".to_string(),
694            CircuitConfig {
695                half_open_max_calls: 2,
696                ..default_config()
697            },
698        );
699        trip_open(&mut b, 0);
700        b.can_call(30_000); // triggers Open → HalfOpen
701        assert!(b.is_half_open());
702
703        b.half_open_calls = 1; // simulate one in-flight call
704        assert!(b.can_call(30_001), "second call should be allowed");
705
706        b.half_open_calls = 2; // at capacity
707        assert!(!b.can_call(30_002), "third call should be rejected");
708    }
709
710    // ── 8. Enough successes in HalfOpen close the circuit ────────────────────
711
712    #[test]
713    fn half_open_successes_close_circuit() {
714        let mut b = make_breaker("p8");
715        trip_open(&mut b, 0);
716        b.can_call(30_000); // → HalfOpen
717        b.record_result(success(100), 30_001);
718        assert!(b.is_half_open(), "still HalfOpen after 1 success (need 2)");
719        b.record_result(success(100), 30_002);
720        assert!(
721            b.is_closed(),
722            "should be Closed after success_threshold reached"
723        );
724    }
725
726    // ── 9. Failure in HalfOpen re-trips the circuit ───────────────────────────
727
728    #[test]
729    fn half_open_failure_reopens() {
730        let mut b = make_breaker("p9");
731        trip_open(&mut b, 0);
732        b.can_call(30_000); // → HalfOpen
733        b.record_result(failure(1), 30_001);
734        assert!(b.is_open(), "failure in HalfOpen should re-open circuit");
735    }
736
737    // ── 10. Slow success counts as failure ────────────────────────────────────
738
739    #[test]
740    fn slow_success_counts_as_failure() {
741        let mut b = make_breaker("p10");
742        // slow_call_threshold_ms default = 5_000; duration >= threshold = failure
743        for _ in 0..5 {
744            b.record_result(success(5_000), 0);
745        }
746        assert!(b.is_open(), "slow calls should trip the circuit");
747    }
748
749    // ── 11. Fast success does not count as failure ────────────────────────────
750
751    #[test]
752    fn fast_success_is_not_a_failure() {
753        let mut b = make_breaker("p11");
754        b.record_result(success(4_999), 0);
755        assert!(b.is_closed());
756        assert_eq!(b.consecutive_failures, 0);
757    }
758
759    // ── 12. failure_rate is 0.0 on empty window ───────────────────────────────
760
761    #[test]
762    fn failure_rate_empty_window() {
763        let b = make_breaker("p12");
764        assert_eq!(b.failure_rate(), 0.0);
765    }
766
767    // ── 13. failure_rate calculation ──────────────────────────────────────────
768
769    #[test]
770    fn failure_rate_calculation() {
771        let mut b = make_breaker("p13");
772        b.record_result(success(1), 0);
773        b.record_result(failure(1), 0);
774        b.record_result(failure(1), 0);
775        b.record_result(success(1), 0);
776        // window: [true, false, false, true] → 2/4 = 0.5
777        let rate = b.failure_rate();
778        assert!((rate - 0.5).abs() < 1e-9, "rate={rate}");
779    }
780
781    // ── 14. Sliding window evicts oldest on overflow ──────────────────────────
782
783    #[test]
784    fn sliding_window_evicts_oldest() {
785        let mut b = PeerCircuitBreaker::new(
786            "p14".to_string(),
787            CircuitConfig {
788                window_size: 3,
789                failure_threshold: 100, // prevent tripping
790                ..default_config()
791            },
792        );
793        b.record_result(failure(1), 0);
794        b.record_result(failure(1), 0);
795        b.record_result(failure(1), 0);
796        assert_eq!(b.window.len(), 3);
797        // Adding one more should evict the oldest failure.
798        b.record_result(success(1), 0);
799        assert_eq!(b.window.len(), 3);
800        // Now 2 failures + 1 success remain.
801        let rate = b.failure_rate();
802        assert!((rate - 2.0 / 3.0).abs() < 1e-9, "rate={rate}");
803    }
804
805    // ── 15. reset() clears all state ─────────────────────────────────────────
806
807    #[test]
808    fn reset_clears_state() {
809        let mut b = make_breaker("p15");
810        trip_open(&mut b, 0);
811        b.reset(1000);
812        assert!(b.is_closed());
813        assert_eq!(b.consecutive_failures, 0);
814        assert_eq!(b.consecutive_successes, 0);
815        assert!(b.window.is_empty());
816        assert_eq!(b.last_closed_at, Some(1000));
817    }
818
819    // ── 16. stats() reflects current state ───────────────────────────────────
820
821    #[test]
822    fn stats_reflects_current_state() {
823        let mut b = make_breaker("p16");
824        b.total_calls = 10;
825        b.rejected_calls = 2;
826        b.record_result(failure(1), 0);
827        let s = b.stats();
828        assert_eq!(s.state, "Closed");
829        assert_eq!(s.total_calls, 10);
830        assert_eq!(s.rejected_calls, 2);
831        assert_eq!(s.consecutive_failures, 1);
832    }
833
834    // ── 17. stats() shows Open state ──────────────────────────────────────────
835
836    #[test]
837    fn stats_shows_open_state() {
838        let mut b = make_breaker("p17");
839        trip_open(&mut b, 42);
840        let s = b.stats();
841        assert_eq!(s.state, "Open");
842        assert_eq!(s.last_opened_at, Some(42));
843    }
844
845    // ── 18. last_opened_at set on trip ───────────────────────────────────────
846
847    #[test]
848    fn last_opened_at_set_on_trip() {
849        let mut b = make_breaker("p18");
850        trip_open(&mut b, 9999);
851        assert_eq!(b.last_opened_at, Some(9999));
852    }
853
854    // ── 19. last_closed_at set on recovery ────────────────────────────────────
855
856    #[test]
857    fn last_closed_at_set_on_recovery() {
858        let mut b = make_breaker("p19");
859        trip_open(&mut b, 0);
860        b.can_call(30_000); // → HalfOpen
861        b.record_result(success(1), 30_001);
862        b.record_result(success(1), 30_002);
863        assert!(b.is_closed());
864        assert_eq!(b.last_closed_at, Some(30_002));
865    }
866
867    // ── 20. Timeout CallResult is always a failure ────────────────────────────
868
869    #[test]
870    fn timeout_result_is_failure() {
871        let r = timeout_result(100);
872        assert!(r.is_failure(5_000), "Timeout should always be a failure");
873    }
874
875    // ── 21. Failure CallResult is always a failure ────────────────────────────
876
877    #[test]
878    fn failure_result_is_failure() {
879        let r = failure(1);
880        assert!(r.is_failure(5_000));
881    }
882
883    // ── 22. Success with duration < threshold is not a failure ────────────────
884
885    #[test]
886    fn success_below_threshold_not_failure() {
887        let r = success(4_999);
888        assert!(!r.is_failure(5_000));
889    }
890
891    // ── 23. Success with duration == threshold is a failure ───────────────────
892
893    #[test]
894    fn success_at_threshold_is_failure() {
895        let r = success(5_000);
896        assert!(r.is_failure(5_000));
897    }
898
899    // ── 24. Registry: get_or_create creates new breaker ──────────────────────
900
901    #[test]
902    fn registry_creates_new_breaker() {
903        let mut reg = CircuitBreakerRegistry::new(default_config());
904        let b = reg.get_or_create("r1");
905        assert!(b.is_closed());
906    }
907
908    // ── 25. Registry: can_call delegates correctly ────────────────────────────
909
910    #[test]
911    fn registry_can_call_delegates() {
912        let mut reg = CircuitBreakerRegistry::new(default_config());
913        assert!(reg.can_call("r2", 0));
914    }
915
916    // ── 26. Registry: record_result increments total_calls ───────────────────
917
918    #[test]
919    fn registry_record_result_increments_total_calls() {
920        let mut reg = CircuitBreakerRegistry::new(default_config());
921        reg.record_result("r3", success(1), 0);
922        reg.record_result("r3", success(1), 0);
923        assert_eq!(reg.get_or_create("r3").total_calls, 2);
924    }
925
926    // ── 27. Registry: open_peers returns only Open peers ─────────────────────
927
928    #[test]
929    fn registry_open_peers() {
930        let mut reg = CircuitBreakerRegistry::new(default_config());
931        // Trip r4 but not r5.
932        for _ in 0..5 {
933            reg.record_result("r4", failure(1), 0);
934        }
935        reg.record_result("r5", success(1), 0);
936        let open = reg.open_peers(0);
937        assert!(open.contains(&"r4".to_string()));
938        assert!(!open.contains(&"r5".to_string()));
939    }
940
941    // ── 28. Registry: half_open_peers returns only HalfOpen peers ────────────
942
943    #[test]
944    fn registry_half_open_peers() {
945        let mut reg = CircuitBreakerRegistry::new(default_config());
946        // Trip then allow timeout.
947        for _ in 0..5 {
948            reg.record_result("r6", failure(1), 0);
949        }
950        reg.can_call("r6", 30_000); // triggers → HalfOpen
951        let ho = reg.half_open_peers();
952        assert!(ho.contains(&"r6".to_string()));
953    }
954
955    // ── 29. Registry: reset_peer returns false for unknown peer ───────────────
956
957    #[test]
958    fn registry_reset_peer_unknown() {
959        let mut reg = CircuitBreakerRegistry::new(default_config());
960        assert!(!reg.reset_peer("nobody", 0));
961    }
962
963    // ── 30. Registry: reset_peer closes an Open circuit ──────────────────────
964
965    #[test]
966    fn registry_reset_peer_closes_open() {
967        let mut reg = CircuitBreakerRegistry::new(default_config());
968        for _ in 0..5 {
969            reg.record_result("r7", failure(1), 0);
970        }
971        assert!(reg.reset_peer("r7", 100));
972        assert!(reg.get_or_create("r7").is_closed());
973    }
974
975    // ── 31. Registry: evict_closed_peers removes low-traffic peers ────────────
976
977    #[test]
978    fn registry_evict_closed_peers() {
979        let mut reg = CircuitBreakerRegistry::new(default_config());
980        reg.record_result("low", success(1), 0); // 1 total call
981        reg.record_result("high", success(1), 0);
982        reg.record_result("high", success(1), 0);
983        reg.record_result("high", success(1), 0); // 3 total calls
984
985        let evicted = reg.evict_closed_peers(2); // min_calls = 2
986        assert_eq!(evicted, 1, "should evict 'low' (1 call < 2)");
987        assert!(!reg.breakers.contains_key("low"));
988        assert!(reg.breakers.contains_key("high"));
989    }
990
991    // ── 32. Registry: evict does NOT remove Open peers ────────────────────────
992
993    #[test]
994    fn registry_evict_skips_open_peers() {
995        let mut reg = CircuitBreakerRegistry::new(default_config());
996        for _ in 0..5 {
997            reg.record_result("open-peer", failure(1), 0);
998        }
999        // open-peer has 5 calls but is Open; min_calls = 10 would evict it if
1000        // Closed, but it should be kept because it is Open.
1001        let evicted = reg.evict_closed_peers(10);
1002        assert_eq!(evicted, 0, "should not evict Open peer");
1003        assert!(reg.breakers.contains_key("open-peer"));
1004    }
1005
1006    // ── 33. Registry: registry_stats counts states correctly ─────────────────
1007
1008    #[test]
1009    fn registry_stats_counts() {
1010        let mut reg = CircuitBreakerRegistry::new(default_config());
1011        reg.record_result("s1", success(1), 0); // Closed
1012        for _ in 0..5 {
1013            reg.record_result("s2", failure(1), 0); // Open
1014        }
1015        let stats: RegistryStats = reg.registry_stats(0);
1016        assert_eq!(stats.total_peers, 2);
1017        assert_eq!(stats.closed_count, 1);
1018        assert_eq!(stats.open_count, 1);
1019        assert_eq!(stats.half_open_count, 0);
1020    }
1021
1022    // ── 34. Registry: total_rejected_calls aggregates correctly ───────────────
1023
1024    #[test]
1025    fn registry_stats_total_rejected() {
1026        let mut reg = CircuitBreakerRegistry::new(default_config());
1027        // Trip peer and then record rejections manually.
1028        for _ in 0..5 {
1029            reg.record_result("rj", failure(1), 0);
1030        }
1031        {
1032            let b = reg.get_or_create("rj");
1033            b.rejected_calls = 7;
1034        }
1035        let stats = reg.registry_stats(0);
1036        assert_eq!(stats.total_rejected_calls, 7);
1037    }
1038
1039    // ── 35. Multiple trips accumulate last_opened_at ──────────────────────────
1040
1041    #[test]
1042    fn multiple_trips_update_last_opened_at() {
1043        let mut b = make_breaker("p35");
1044        trip_open(&mut b, 100);
1045        assert_eq!(b.last_opened_at, Some(100));
1046
1047        // Recover.
1048        b.can_call(130_100); // → HalfOpen
1049        b.record_result(success(1), 130_101);
1050        b.record_result(success(1), 130_102); // → Closed
1051
1052        // Trip again at a later time.
1053        trip_open(&mut b, 200_000);
1054        assert_eq!(b.last_opened_at, Some(200_000));
1055    }
1056
1057    // ── 36. Timeout CallResult trips the circuit ──────────────────────────────
1058
1059    #[test]
1060    fn timeout_result_trips_circuit() {
1061        let mut b = make_breaker("p36");
1062        for _ in 0..5 {
1063            b.record_result(timeout_result(10_000), 0);
1064        }
1065        assert!(b.is_open(), "repeated timeouts should trip circuit Open");
1066    }
1067
1068    // ── 37. Window counts match after mixed results ───────────────────────────
1069
1070    #[test]
1071    fn window_counts_after_mixed_results() {
1072        let mut b = PeerCircuitBreaker::new(
1073            "p37".to_string(),
1074            CircuitConfig {
1075                window_size: 6,
1076                failure_threshold: 100,
1077                ..default_config()
1078            },
1079        );
1080        for _ in 0..3 {
1081            b.record_result(success(1), 0);
1082        }
1083        for _ in 0..3 {
1084            b.record_result(failure(1), 0);
1085        }
1086        let s = b.stats();
1087        assert_eq!(s.success_count, 3);
1088        assert_eq!(s.failure_count, 3);
1089    }
1090
1091    // ── 38. CircuitStats: last_opened_at / last_closed_at from stats() ────────
1092
1093    #[test]
1094    fn stats_timestamps_propagated() {
1095        let mut b = make_breaker("p38");
1096        trip_open(&mut b, 5555);
1097        b.can_call(35_555); // → HalfOpen
1098        b.record_result(success(1), 35_556);
1099        b.record_result(success(1), 35_557); // → Closed
1100
1101        let s = b.stats();
1102        assert_eq!(s.last_opened_at, Some(5555));
1103        assert_eq!(s.last_closed_at, Some(35_557));
1104    }
1105
1106    // ── 39. is_closed / is_open / is_half_open helpers ───────────────────────
1107
1108    #[test]
1109    fn state_helpers_correct() {
1110        let mut b = make_breaker("p39");
1111        assert!(b.is_closed());
1112        assert!(!b.is_open());
1113        assert!(!b.is_half_open());
1114
1115        trip_open(&mut b, 0);
1116        assert!(!b.is_closed());
1117        assert!(b.is_open());
1118        assert!(!b.is_half_open());
1119
1120        b.can_call(30_000);
1121        assert!(!b.is_closed());
1122        assert!(!b.is_open());
1123        assert!(b.is_half_open());
1124    }
1125
1126    // ── 40. Registry is_empty / len ──────────────────────────────────────────
1127
1128    #[test]
1129    fn registry_len_and_is_empty() {
1130        let mut reg = CircuitBreakerRegistry::new(default_config());
1131        assert!(reg.is_empty());
1132        assert_eq!(reg.len(), 0);
1133        reg.can_call("x", 0);
1134        assert!(!reg.is_empty());
1135        assert_eq!(reg.len(), 1);
1136    }
1137}