Skip to main content

oxi_ai/
circuit_breaker.rs

1//! Per-provider circuit breaker implementation.
2//!
3//! This module provides a thread-safe circuit breaker pattern for managing
4//! provider failures in the oxi-ai library. Each provider can have its own
5//! circuit breaker instance that prevents cascading failures by temporarily
6//! blocking requests to unhealthy providers.
7//!
8//! # Circuit States
9//!
10//! - **Closed**: Normal operation. All requests are allowed through.
11//!   Failures are counted, and the circuit opens after reaching the threshold.
12//!
13//! - **Open**: The provider is considered unhealthy. Requests are blocked
14//!   for a configurable duration, then the circuit transitions to half-open
15//!   to test recovery.
16//!
17//! - **Half-Open**: Recovery testing mode. A limited number of requests
18//!   are allowed to test if the provider has recovered. If enough succeed,
19//!   the circuit closes; if any fail, the circuit reopens.
20//!
21//! # Example
22//!
23//! ```rust
24//! use std::time::Duration;
25//! use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
26//!
27//! let config = CircuitBreakerConfig::default();
28//! let breaker = ProviderCircuitBreaker::new("openai".to_string(), config);
29//!
30//! // Check if request is allowed
31//! match breaker.allow_request() {
32//!     Ok(()) => { /* proceed with request */ }
33//!     Err(e) => { /* circuit is open, retry after e.remaining */ }
34//! }
35//! ```
36//!
37//! # Thread Safety
38//!
39//! All state is managed using atomic operations and parking_lot mutex,
40//! making this implementation safe for concurrent access from multiple
41//! async tasks or threads.
42
43use parking_lot::Mutex;
44use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
45use std::time::{Duration, Instant};
46use thiserror::Error;
47
48// ============================================================================
49// Circuit State
50// ============================================================================
51
52/// Circuit breaker states.
53///
54/// The state is stored as a `u8` in an atomic, so these values correspond
55/// to the numeric representation (0, 1, 2) for efficient atomic operations.
56#[repr(u8)]
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CircuitState {
59    /// Normal operation. All requests are allowed through.
60    /// Failures increment the consecutive failure counter.
61    Closed = 0,
62
63    /// Provider is unhealthy. Requests are blocked for a configured duration.
64    /// After the duration elapses, transitions to `HalfOpen`.
65    Open = 1,
66
67    /// Recovery testing mode. Limited requests are allowed.
68    /// Successes are counted; circuit closes after `half_open_successes` succeed.
69    /// Any failure reopens the circuit.
70    HalfOpen = 2,
71}
72
73impl CircuitState {
74    /// Convert a raw u8 value to a `CircuitState`.
75    ///
76    /// Returns `CircuitState::HalfOpen` for any value >= 2 to handle
77    /// potential future state additions gracefully.
78    #[inline]
79    fn from_u8(value: u8) -> Self {
80        match value {
81            0 => Self::Closed,
82            1 => Self::Open,
83            _ => Self::HalfOpen,
84        }
85    }
86
87    /// Convert `CircuitState` to its numeric representation.
88    #[inline]
89    fn as_u8(&self) -> u8 {
90        *self as u8
91    }
92}
93
94// ============================================================================
95// Circuit Breaker Configuration
96// ============================================================================
97
98/// Configuration parameters for a provider circuit breaker.
99///
100/// All parameters can be tuned based on the provider's reliability and
101/// the acceptable impact of failures on your application.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CircuitBreakerConfig {
104    /// Number of consecutive failures required to open the circuit.
105    ///
106    /// Default: 5
107    ///
108    /// A lower value makes the circuit more sensitive to failures,
109    /// while a higher value requires more failures before opening.
110    pub failure_threshold: u32,
111
112    /// Duration to keep the circuit open before transitioning to half-open.
113    ///
114    /// Default: 30 seconds
115    ///
116    /// This should be long enough for the provider to recover from
117    /// whatever caused the failures (e.g., rate limits, temporary outages).
118    pub open_duration: Duration,
119
120    /// Number of successful requests required in half-open state to close the circuit.
121    ///
122    /// Default: 1
123    ///
124    /// Setting this higher makes recovery testing more conservative,
125    /// requiring multiple successful requests before fully trusting the provider.
126    pub half_open_successes: u32,
127}
128
129impl Default for CircuitBreakerConfig {
130    /// Creates a default circuit breaker configuration.
131    ///
132    /// The defaults are tuned for general-purpose use:
133    /// - 5 failures before opening (reasonable for most APIs)
134    /// - 30 second cooldown (enough for temporary issues to resolve)
135    /// - 1 success to close (fast recovery testing)
136    fn default() -> Self {
137        Self {
138            failure_threshold: 5,
139            open_duration: Duration::from_secs(30),
140            half_open_successes: 1,
141        }
142    }
143}
144
145impl CircuitBreakerConfig {
146    /// Creates a new configuration with all values explicitly set.
147    ///
148    /// # Arguments
149    ///
150    /// * `failure_threshold` - Consecutive failures to trigger circuit opening
151    /// * `open_duration` - Time to wait before testing recovery
152    /// * `half_open_successes` - Successes needed in half-open to close circuit
153    ///
154    /// # Panics
155    ///
156    /// Panics if `failure_threshold` or `half_open_successes` are zero,
157    /// as this would create an immediately opening circuit.
158    #[inline]
159    pub fn new(failure_threshold: u32, open_duration: Duration, half_open_successes: u32) -> Self {
160        if failure_threshold == 0 {
161            panic!("failure_threshold cannot be zero");
162        }
163        if half_open_successes == 0 {
164            panic!("half_open_successes cannot be zero");
165        }
166        Self {
167            failure_threshold,
168            open_duration,
169            half_open_successes,
170        }
171    }
172
173    /// Sets the failure threshold.
174    ///
175    /// Returns a new configuration with the updated value.
176    #[inline]
177    #[must_use]
178    pub fn with_failure_threshold(mut self, threshold: u32) -> Self {
179        self.failure_threshold = threshold;
180        self
181    }
182
183    /// Sets the open duration.
184    ///
185    /// Returns a new configuration with the updated value.
186    #[inline]
187    #[must_use]
188    pub fn with_open_duration(mut self, duration: Duration) -> Self {
189        self.open_duration = duration;
190        self
191    }
192
193    /// Sets the half-open successes required.
194    ///
195    /// Returns a new configuration with the updated value.
196    #[inline]
197    #[must_use]
198    pub fn with_half_open_successes(mut self, successes: u32) -> Self {
199        self.half_open_successes = successes;
200        self
201    }
202}
203
204// ============================================================================
205// Circuit Open Error
206// ============================================================================
207
208/// Error returned when attempting to make a request while the circuit is open.
209///
210/// This error indicates that the circuit breaker has blocked the request
211/// because the provider is considered unhealthy. The `remaining` field
212/// indicates how long you should wait before attempting another request.
213#[derive(Debug, Error, Clone, PartialEq, Eq)]
214#[error("Circuit breaker open for provider '{provider}': retry after {remaining:?}")]
215pub struct CircuitOpenError {
216    /// The name of the provider whose circuit is open.
217    pub provider: String,
218    /// Time remaining before the circuit transitions to half-open.
219    pub remaining: Duration,
220}
221
222impl CircuitOpenError {
223    /// Creates a new circuit open error for the given provider and duration.
224    #[inline]
225    pub fn new(provider: impl Into<String>, remaining: Duration) -> Self {
226        Self {
227            provider: provider.into(),
228            remaining,
229        }
230    }
231}
232
233// ============================================================================
234// Provider Circuit Breaker
235// ============================================================================
236
237/// A per-provider circuit breaker for preventing cascading failures.
238///
239/// This struct manages the state machine for a single provider's circuit breaker.
240/// It tracks consecutive failures and successes, manages state transitions,
241/// and determines whether requests should be allowed.
242///
243/// # Thread Safety
244///
245/// All operations are thread-safe and can be called concurrently from
246/// multiple async tasks or threads. The implementation uses atomic
247/// operations for the fast path (checking state) and a mutex only for
248/// the timestamp update when opening the circuit.
249///
250/// # State Machine
251///
252/// ```text
253/// ┌─────────┐  failure_threshold reached   ┌────────┐
254/// │ Closed  │ ───────────────────────────►  │  Open  │
255/// └────┬────┘                               └───┬────┘
256///      │                                         │
257///      │ record_success()                         │ open_duration elapsed
258///      │ (reset failures to 0)                    ▼
259///      │                                   ┌───────────┐
260///      │                                   │ Half-Open │
261///      │                                   └─────┬─────┘
262///      │                                         │
263///      │                              half_open_successes reached
264///      └─────────────────────────────────────────┘
265///      │                                         ▲
266///      │         any failure                     │
267///      └─────────────────────────────────────────┘
268/// ```
269///
270/// # Example
271///
272/// ```rust
273/// use std::time::Duration;
274/// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
275///
276/// let config = CircuitBreakerConfig::default();
277/// let breaker = ProviderCircuitBreaker::new("anthropic".to_string(), config);
278///
279/// // Check if a request is allowed
280/// match breaker.allow_request() {
281///     Ok(()) => {
282///         // Proceed with the request
283///     }
284///     Err(e) => {
285///         println!("Circuit open: {}", e);
286///     }
287/// }
288/// ```
289#[derive(Debug)]
290pub struct ProviderCircuitBreaker {
291    /// Identifier for the provider this breaker protects.
292    provider_name: String,
293
294    /// Configuration parameters for this circuit breaker.
295    config: CircuitBreakerConfig,
296
297    /// Current circuit state (0=Closed, 1=Open, 2=HalfOpen).
298    /// Stored as atomic for lock-free reads.
299    state: AtomicU8,
300
301    /// Count of consecutive failures since last success in closed state.
302    consecutive_failures: AtomicU64,
303
304    /// Count of consecutive successes in half-open state.
305    consecutive_successes: AtomicU64,
306
307    /// Timestamp when the circuit was opened.
308    /// Protected by mutex because it's rarely accessed (only in Open state).
309    opened_at: Mutex<Option<Instant>>,
310}
311
312impl ProviderCircuitBreaker {
313    /// Creates a new circuit breaker for the specified provider.
314    ///
315    /// # Arguments
316    ///
317    /// * `provider_name` - Identifier for the provider (e.g., "openai", "anthropic")
318    /// * `config` - Circuit breaker configuration parameters
319    ///
320    /// # Example
321    ///
322    /// ```rust
323    /// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
324    ///
325    /// let breaker = ProviderCircuitBreaker::new(
326    ///     "openai".to_string(),
327    ///     CircuitBreakerConfig::default(),
328    /// );
329    /// ```
330    #[inline]
331    pub fn new(provider_name: String, config: CircuitBreakerConfig) -> Self {
332        Self {
333            provider_name,
334            config,
335            state: AtomicU8::new(CircuitState::Closed.as_u8()),
336            consecutive_failures: AtomicU64::new(0),
337            consecutive_successes: AtomicU64::new(0),
338            opened_at: Mutex::new(None),
339        }
340    }
341
342    /// Creates a new circuit breaker with default configuration.
343    ///
344    /// # Arguments
345    ///
346    /// * `provider_name` - Identifier for the provider
347    ///
348    /// # Example
349    ///
350    /// ```rust
351    /// use oxi_ai::circuit_breaker::ProviderCircuitBreaker;
352    ///
353    /// let breaker = ProviderCircuitBreaker::with_defaults("openai".to_string());
354    /// assert!(breaker.allow_request().is_ok());
355    /// ```
356    #[inline]
357    pub fn with_defaults(provider_name: String) -> Self {
358        Self::new(provider_name, CircuitBreakerConfig::default())
359    }
360
361    /// Checks whether a request should be allowed to proceed.
362    ///
363    /// Returns `Ok(())` if the request is allowed, or `Err(CircuitOpenError)`
364    /// if the circuit is open and requests are blocked.
365    ///
366    /// # State Transitions
367    ///
368    /// - **Closed**: Always allows, but first call in Open state with elapsed
369    ///   duration transitions to HalfOpen.
370    ///
371    /// - **Open**: Blocks requests. If `open_duration` has elapsed since
372    ///   opening, transitions to HalfOpen and allows this request.
373    ///
374    /// - **HalfOpen**: Always allows (limited probe requests).
375    ///
376    /// # Example
377    ///
378    /// ```rust
379    /// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
380    ///
381    /// let breaker = ProviderCircuitBreaker::new(
382    ///     "openai".to_string(),
383    ///     CircuitBreakerConfig::default(),
384    /// );
385    ///
386    /// match breaker.allow_request() {
387    ///     Ok(()) => {
388    ///         // Proceed with the request
389    ///     }
390    ///     Err(e) => {
391    ///         eprintln!("Circuit open: {}", e);
392    ///     }
393    /// }
394    /// ```
395    pub fn allow_request(&self) -> Result<(), CircuitOpenError> {
396        let state = self.load_state();
397
398        match state {
399            CircuitState::Closed => {
400                // Closed: always allow requests
401                Ok(())
402            }
403
404            CircuitState::Open => {
405                // Open: check if duration has elapsed
406                let opened_at = self.opened_at.lock();
407
408                if let Some(timestamp) = *opened_at {
409                    let elapsed = timestamp.elapsed();
410
411                    if elapsed >= self.config.open_duration {
412                        // Duration elapsed: transition to half-open
413                        drop(opened_at);
414                        self.state
415                            .store(CircuitState::HalfOpen.as_u8(), Ordering::SeqCst);
416                        self.consecutive_successes.store(0, Ordering::SeqCst);
417                        return Ok(());
418                    }
419
420                    // Still in cooldown period
421                    let remaining = self.config.open_duration.saturating_sub(elapsed);
422                    return Err(CircuitOpenError::new(&self.provider_name, remaining));
423                }
424
425                // No timestamp recorded somehow; treat as half-open
426                drop(opened_at);
427                self.state
428                    .store(CircuitState::HalfOpen.as_u8(), Ordering::SeqCst);
429                Ok(())
430            }
431
432            CircuitState::HalfOpen => {
433                // HalfOpen: allow probe requests
434                Ok(())
435            }
436        }
437    }
438
439    /// Records a successful request.
440    ///
441    /// Updates internal counters based on current state:
442    ///
443    /// - **Closed**: Resets failure counter to zero.
444    ///
445    /// - **HalfOpen**: Increments success counter. If threshold reached,
446    ///   closes the circuit (transitions to Closed).
447    ///
448    /// - **Open**: No effect (successes don't matter while waiting for cooldown).
449    ///
450    /// # Example
451    ///
452    /// ```rust
453    /// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
454    ///
455    /// let breaker = ProviderCircuitBreaker::new(
456    ///     "openai".to_string(),
457    ///     CircuitBreakerConfig::default(),
458    /// );
459    ///
460    /// // Simulate a successful request
461    /// breaker.record_success();
462    /// ```
463    pub fn record_success(&self) {
464        let state = self.load_state();
465
466        match state {
467            CircuitState::Closed => {
468                // Reset failure counter on success in closed state
469                self.consecutive_failures.store(0, Ordering::SeqCst);
470            }
471
472            CircuitState::HalfOpen => {
473                // Count successes in half-open state
474                let prev = self.consecutive_successes.fetch_add(1, Ordering::SeqCst);
475                let new_count = prev + 1;
476
477                if new_count >= self.config.half_open_successes as u64 {
478                    // Enough successes: close the circuit
479                    self.state
480                        .store(CircuitState::Closed.as_u8(), Ordering::SeqCst);
481                    self.consecutive_failures.store(0, Ordering::SeqCst);
482                    self.consecutive_successes.store(0, Ordering::SeqCst);
483                    // Clear the opened_at timestamp
484                    *self.opened_at.lock() = None;
485                }
486            }
487
488            CircuitState::Open => {
489                // No action needed while circuit is open
490            }
491        }
492    }
493
494    /// Records a failed request.
495    ///
496    /// Updates internal counters and may trigger state transitions:
497    ///
498    /// - **Closed**: Increments failure counter. If threshold reached,
499    ///   opens the circuit (records `Instant::now()` as opening time).
500    ///
501    /// - **HalfOpen**: Any failure reopens the circuit immediately.
502    ///
503    /// - **Open**: No additional effect (already tracking the failure).
504    ///
505    /// # Example
506    ///
507    /// ```rust
508    /// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
509    ///
510    /// let breaker = ProviderCircuitBreaker::new(
511    ///     "openai".to_string(),
512    ///     CircuitBreakerConfig::default(),
513    /// );
514    ///
515    /// // Simulate a failed request
516    /// breaker.record_failure();
517    /// ```
518    pub fn record_failure(&self) {
519        let state = self.load_state();
520
521        match state {
522            CircuitState::Closed => {
523                // Increment failure counter
524                let prev = self.consecutive_failures.fetch_add(1, Ordering::SeqCst);
525                let new_count = prev + 1;
526
527                if new_count >= self.config.failure_threshold as u64 {
528                    // Threshold reached: open the circuit
529                    self.state
530                        .store(CircuitState::Open.as_u8(), Ordering::SeqCst);
531                    *self.opened_at.lock() = Some(Instant::now());
532                }
533            }
534
535            CircuitState::HalfOpen => {
536                // Any failure in half-open reopens the circuit
537                self.state
538                    .store(CircuitState::Open.as_u8(), Ordering::SeqCst);
539                *self.opened_at.lock() = Some(Instant::now());
540            }
541
542            CircuitState::Open => {
543                // Already open; no additional action needed
544            }
545        }
546    }
547
548    /// Manually resets the circuit breaker to the closed state.
549    ///
550    /// This is useful for:
551    /// - Administrative intervention after fixing provider issues
552    /// - Testing and development
553    /// - Implementing custom reset logic
554    ///
555    /// After reset:
556    /// - State becomes Closed
557    /// - Consecutive failures reset to 0
558    /// - Consecutive successes reset to 0
559    /// - Opening timestamp is cleared
560    ///
561    /// # Example
562    ///
563    /// ```rust
564    /// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
565    ///
566    /// let breaker = ProviderCircuitBreaker::new(
567    ///     "openai".to_string(),
568    ///     CircuitBreakerConfig::default(),
569    /// );
570    ///
571    /// // Manually reset the circuit
572    /// breaker.reset();
573    /// ```
574    pub fn reset(&self) {
575        self.state
576            .store(CircuitState::Closed.as_u8(), Ordering::SeqCst);
577        self.consecutive_failures.store(0, Ordering::SeqCst);
578        self.consecutive_successes.store(0, Ordering::SeqCst);
579        *self.opened_at.lock() = None;
580    }
581
582    /// Returns the current circuit state.
583    ///
584    /// This is a snapshot and may change immediately after being read.
585    /// For decision-making, prefer `allow_request()` which handles state
586    /// transitions atomically.
587    #[inline]
588    pub fn state(&self) -> CircuitState {
589        self.load_state()
590    }
591
592    /// Returns the provider name this circuit breaker protects.
593    #[inline]
594    pub fn provider_name(&self) -> &str {
595        &self.provider_name
596    }
597
598    /// Returns a reference to the configuration.
599    #[inline]
600    pub fn config(&self) -> &CircuitBreakerConfig {
601        &self.config
602    }
603
604    /// Returns the number of consecutive failures.
605    ///
606    /// This is useful for monitoring and debugging.
607    #[inline]
608    pub fn consecutive_failures(&self) -> u64 {
609        self.consecutive_failures.load(Ordering::SeqCst)
610    }
611
612    /// Returns the number of consecutive successes (in half-open state).
613    ///
614    /// This is useful for monitoring and debugging.
615    #[inline]
616    pub fn consecutive_successes(&self) -> u64 {
617        self.consecutive_successes.load(Ordering::SeqCst)
618    }
619
620    /// Returns the time remaining before the circuit transitions to half-open.
621    ///
622    /// Returns `None` if the circuit is not in the open state.
623    #[inline]
624    pub fn remaining_open_time(&self) -> Option<Duration> {
625        if self.load_state() == CircuitState::Open {
626            let opened_at = self.opened_at.lock();
627            opened_at.map(|t| {
628                let elapsed = t.elapsed();
629                self.config.open_duration.saturating_sub(elapsed)
630            })
631        } else {
632            None
633        }
634    }
635
636    /// Loads the current state from the atomic value.
637    #[inline]
638    fn load_state(&self) -> CircuitState {
639        CircuitState::from_u8(self.state.load(Ordering::SeqCst))
640    }
641}
642
643// ============================================================================
644// Diagnostic Info
645// ============================================================================
646
647/// Provides diagnostic information about a circuit breaker's current state.
648///
649/// This struct is useful for monitoring dashboards, logging, and debugging.
650#[derive(Debug, Clone, PartialEq, Eq)]
651pub struct CircuitBreakerDiagnostics {
652    /// The provider this breaker protects.
653    pub provider: String,
654    /// Current state.
655    pub state: CircuitState,
656    /// Number of consecutive failures.
657    pub consecutive_failures: u64,
658    /// Number of consecutive successes (in half-open).
659    pub consecutive_successes: u64,
660    /// Whether the circuit is currently open.
661    pub is_open: bool,
662    /// Time remaining in open state, if applicable.
663    pub remaining_open_time: Option<Duration>,
664}
665
666impl ProviderCircuitBreaker {
667    /// Returns diagnostic information about this circuit breaker.
668    ///
669    /// # Example
670    ///
671    /// ```rust
672    /// use oxi_ai::circuit_breaker::{CircuitBreakerConfig, ProviderCircuitBreaker};
673    ///
674    /// let breaker = ProviderCircuitBreaker::new(
675    ///     "openai".to_string(),
676    ///     CircuitBreakerConfig::default(),
677    /// );
678    ///
679    /// let diagnostics = breaker.diagnostics();
680    /// println!("Provider: {}", diagnostics.provider);
681    /// ```
682    pub fn diagnostics(&self) -> CircuitBreakerDiagnostics {
683        let state = self.load_state();
684        CircuitBreakerDiagnostics {
685            provider: self.provider_name.clone(),
686            state,
687            consecutive_failures: self.consecutive_failures(),
688            consecutive_successes: self.consecutive_successes(),
689            is_open: state == CircuitState::Open,
690            remaining_open_time: self.remaining_open_time(),
691        }
692    }
693}
694
695// ============================================================================
696// Tests
697// ============================================================================
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    // ========================================================================
704    // CircuitState Tests
705    // ========================================================================
706
707    #[test]
708    fn circuit_state_from_u8() {
709        assert_eq!(CircuitState::from_u8(0), CircuitState::Closed);
710        assert_eq!(CircuitState::from_u8(1), CircuitState::Open);
711        assert_eq!(CircuitState::from_u8(2), CircuitState::HalfOpen);
712        assert_eq!(CircuitState::from_u8(255), CircuitState::HalfOpen); // Unknown values map to HalfOpen
713    }
714
715    #[test]
716    fn circuit_state_as_u8() {
717        assert_eq!(CircuitState::Closed.as_u8(), 0);
718        assert_eq!(CircuitState::Open.as_u8(), 1);
719        assert_eq!(CircuitState::HalfOpen.as_u8(), 2);
720    }
721
722    // ========================================================================
723    // CircuitBreakerConfig Tests
724    // ========================================================================
725
726    #[test]
727    fn config_default() {
728        let config = CircuitBreakerConfig::default();
729        assert_eq!(config.failure_threshold, 5);
730        assert_eq!(config.open_duration, Duration::from_secs(30));
731        assert_eq!(config.half_open_successes, 1);
732    }
733
734    #[test]
735    fn config_new_valid() {
736        let config = CircuitBreakerConfig::new(3, Duration::from_secs(10), 2);
737        assert_eq!(config.failure_threshold, 3);
738        assert_eq!(config.open_duration, Duration::from_secs(10));
739        assert_eq!(config.half_open_successes, 2);
740    }
741
742    #[test]
743    #[should_panic(expected = "failure_threshold cannot be zero")]
744    fn config_new_zero_failure_threshold() {
745        CircuitBreakerConfig::new(0, Duration::from_secs(10), 1);
746    }
747
748    #[test]
749    #[should_panic(expected = "half_open_successes cannot be zero")]
750    fn config_new_zero_half_open_successes() {
751        CircuitBreakerConfig::new(3, Duration::from_secs(10), 0);
752    }
753
754    #[test]
755    fn config_builder_methods() {
756        let config = CircuitBreakerConfig::default()
757            .with_failure_threshold(10)
758            .with_open_duration(Duration::from_secs(60))
759            .with_half_open_successes(2);
760
761        assert_eq!(config.failure_threshold, 10);
762        assert_eq!(config.open_duration, Duration::from_secs(60));
763        assert_eq!(config.half_open_successes, 2);
764    }
765
766    // ========================================================================
767    // ProviderCircuitBreaker Tests
768    // ========================================================================
769
770    #[test]
771    fn breaker_allows_when_closed() {
772        let breaker = ProviderCircuitBreaker::with_defaults("test".to_string());
773        assert!(breaker.allow_request().is_ok());
774        assert_eq!(breaker.state(), CircuitState::Closed);
775    }
776
777    #[test]
778    fn breaker_success_in_closed_state() {
779        let breaker = ProviderCircuitBreaker::with_defaults("test".to_string());
780        breaker.record_success();
781        assert_eq!(breaker.consecutive_failures(), 0);
782    }
783
784    #[test]
785    fn breaker_opens_after_threshold() {
786        let config = CircuitBreakerConfig::new(3, Duration::from_secs(30), 1);
787        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
788
789        // Record failures up to threshold
790        breaker.record_failure();
791        assert_eq!(breaker.state(), CircuitState::Closed);
792        breaker.record_failure();
793        assert_eq!(breaker.state(), CircuitState::Closed);
794        breaker.record_failure();
795        assert_eq!(breaker.state(), CircuitState::Open);
796
797        // Should be blocked now
798        assert!(breaker.allow_request().is_err());
799    }
800
801    #[test]
802    fn breaker_success_resets_failure_count() {
803        let config = CircuitBreakerConfig::new(3, Duration::from_secs(30), 1);
804        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
805
806        breaker.record_failure();
807        breaker.record_failure();
808        assert_eq!(breaker.consecutive_failures(), 2);
809
810        breaker.record_success();
811        assert_eq!(breaker.consecutive_failures(), 0);
812    }
813
814    #[test]
815    fn breaker_reset() {
816        let config = CircuitBreakerConfig::new(1, Duration::from_secs(30), 1);
817        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
818
819        // Open the circuit
820        breaker.record_failure();
821        assert_eq!(breaker.state(), CircuitState::Open);
822
823        // Reset
824        breaker.reset();
825        assert_eq!(breaker.state(), CircuitState::Closed);
826        assert!(breaker.allow_request().is_ok());
827    }
828
829    #[test]
830    fn breaker_half_open_on_duration_elapsed() {
831        let config = CircuitBreakerConfig::new(1, Duration::from_millis(50), 1);
832        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
833
834        // Open the circuit
835        breaker.record_failure();
836        assert_eq!(breaker.state(), CircuitState::Open);
837
838        // Wait for duration to elapse
839        std::thread::sleep(Duration::from_millis(60));
840
841        // Should transition to half-open on allow_request
842        assert!(breaker.allow_request().is_ok());
843        assert_eq!(breaker.state(), CircuitState::HalfOpen);
844    }
845
846    #[test]
847    fn breaker_half_open_success_closes_circuit() {
848        let config = CircuitBreakerConfig::new(1, Duration::from_secs(30), 1);
849        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
850
851        // Force to half-open
852        breaker.reset();
853        breaker
854            .state
855            .store(CircuitState::HalfOpen.as_u8(), Ordering::SeqCst);
856
857        // Record success
858        breaker.record_success();
859        assert_eq!(breaker.state(), CircuitState::Closed);
860    }
861
862    #[test]
863    fn breaker_half_open_failure_reopens() {
864        let config = CircuitBreakerConfig::new(1, Duration::from_secs(30), 1);
865        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
866
867        // Force to half-open
868        breaker.reset();
869        breaker
870            .state
871            .store(CircuitState::HalfOpen.as_u8(), Ordering::SeqCst);
872
873        // Record failure
874        breaker.record_failure();
875        assert_eq!(breaker.state(), CircuitState::Open);
876    }
877
878    #[test]
879    fn breaker_multiple_half_open_successes() {
880        let config = CircuitBreakerConfig::new(1, Duration::from_secs(30), 3);
881        let breaker = ProviderCircuitBreaker::new("test".to_string(), config);
882
883        // Force to half-open
884        breaker.reset();
885        breaker
886            .state
887            .store(CircuitState::HalfOpen.as_u8(), Ordering::SeqCst);
888
889        // Partial successes should not close
890        breaker.record_success();
891        assert_eq!(breaker.state(), CircuitState::HalfOpen);
892        breaker.record_success();
893        assert_eq!(breaker.state(), CircuitState::HalfOpen);
894
895        // Third success closes
896        breaker.record_success();
897        assert_eq!(breaker.state(), CircuitState::Closed);
898    }
899
900    #[test]
901    fn breaker_diagnostics() {
902        let config = CircuitBreakerConfig::new(2, Duration::from_secs(30), 1);
903        let breaker = ProviderCircuitBreaker::new("openai".to_string(), config);
904
905        breaker.record_failure();
906        let diag = breaker.diagnostics();
907
908        assert_eq!(diag.provider, "openai");
909        assert_eq!(diag.state, CircuitState::Closed);
910        assert_eq!(diag.consecutive_failures, 1);
911        assert!(!diag.is_open);
912    }
913
914    #[test]
915    fn breaker_diagnostics_when_open() {
916        let config = CircuitBreakerConfig::new(1, Duration::from_secs(30), 1);
917        let breaker = ProviderCircuitBreaker::new("anthropic".to_string(), config);
918
919        breaker.record_failure();
920        let diag = breaker.diagnostics();
921
922        assert!(diag.is_open);
923        assert!(diag.remaining_open_time.is_some());
924    }
925
926    // ========================================================================
927    // CircuitOpenError Tests
928    // ========================================================================
929
930    #[test]
931    fn circuit_open_error_display() {
932        let error = CircuitOpenError::new("openai", Duration::from_secs(10));
933        let msg = error.to_string();
934        assert!(msg.contains("openai"));
935        assert!(msg.contains("10"));
936    }
937
938    #[test]
939    fn circuit_open_error_clone() {
940        let error = CircuitOpenError::new("test", Duration::from_secs(5));
941        let cloned = error.clone();
942        assert_eq!(error, cloned);
943    }
944}