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}