Skip to main content

oxios_kernel/a2a/
circuit_breaker.rs

1//! A2A protocol circuit breaker for delegation reliability.
2//!
3//! Prevents cascading failures when A2A delegation repeatedly fails.
4//!
5//! # Example
6//!
7//! ```
8//! use oxios_kernel::a2a::circuit_breaker::{A2ACircuitBreaker, CircuitState};
9//!
10//! let cb = A2ACircuitBreaker::new(3, 30);  // 3 failures, 30s reset
11//! assert_eq!(cb.state(), CircuitState::Closed);
12//! assert!(cb.is_allowed());
13//! ```
14
15use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18/// Current wall-clock time in seconds since the UNIX epoch. Stored in the
19/// breaker's `AtomicU64` because `Instant` cannot be encoded as u64. The
20/// previous implementation stored `Instant::now().elapsed().as_secs()`, which
21/// is always 0 — so once Open the breaker never recovered.
22fn now_epoch_secs() -> u64 {
23    SystemTime::now()
24        .duration_since(UNIX_EPOCH)
25        .map(|d| d.as_secs())
26        .unwrap_or(0)
27}
28
29/// Circuit breaker states.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CircuitState {
32    /// Normal operation — requests allowed.
33    Closed,
34    /// Too many failures — requests blocked.
35    Open,
36    /// Testing recovery — limited requests allowed.
37    HalfOpen,
38}
39
40impl CircuitState {
41    fn from_u8(v: u8) -> Self {
42        match v {
43            0 => CircuitState::Closed,
44            1 => CircuitState::Open,
45            2 => CircuitState::HalfOpen,
46            _ => CircuitState::Closed,
47        }
48    }
49}
50
51/// A2A delegation circuit breaker.
52///
53/// Tracks failures and opens the circuit when threshold is exceeded.
54/// After timeout, allows limited test requests (half-open state).
55#[derive(Debug)]
56pub struct A2ACircuitBreaker {
57    state: AtomicU8,
58    failure_count: AtomicU32,
59    success_count: AtomicU32,
60    last_failure_time: AtomicU64,
61    threshold: u32,
62    reset_timeout: Duration,
63}
64
65impl A2ACircuitBreaker {
66    /// Create a new circuit breaker.
67    ///
68    /// # Arguments
69    /// * `threshold` - Number of consecutive failures before opening
70    /// * `reset_timeout_secs` - Seconds to wait before testing recovery
71    pub fn new(threshold: u32, reset_timeout_secs: u64) -> Self {
72        Self {
73            state: AtomicU8::new(CircuitState::Closed as u8),
74            failure_count: AtomicU32::new(0),
75            success_count: AtomicU32::new(0),
76            last_failure_time: AtomicU64::new(0),
77            threshold,
78            reset_timeout: Duration::from_secs(reset_timeout_secs),
79        }
80    }
81
82    /// Current circuit state.
83    ///
84    /// `SeqCst` so a freshly-OPEN breaker is visible to every thread that
85    /// gates request flow on this read (audit F-15 — `Relaxed` could let a
86    /// burst of requests past a transition that hasn't propagated yet).
87    pub fn state(&self) -> CircuitState {
88        CircuitState::from_u8(self.state.load(Ordering::SeqCst))
89    }
90
91    /// Whether a request is allowed through the circuit.
92    pub fn is_allowed(&self) -> bool {
93        match self.state() {
94            CircuitState::Closed => true,
95            CircuitState::Open => {
96                // Check if reset timeout has passed
97                let last_failure = self.last_failure_time.load(Ordering::Acquire);
98                let elapsed = now_epoch_secs().saturating_sub(last_failure);
99                if elapsed > self.reset_timeout.as_secs() {
100                    // Transition to half-open.
101                    self.state
102                        .store(CircuitState::HalfOpen as u8, Ordering::SeqCst);
103                    self.success_count.store(0, Ordering::Release);
104                    true
105                } else {
106                    false
107                }
108            }
109            CircuitState::HalfOpen => {
110                // Allow limited requests (up to 2)
111                self.success_count.load(Ordering::Acquire) < 2
112            }
113        }
114    }
115
116    /// Record a successful request.
117    pub fn record_success(&self) {
118        match self.state() {
119            CircuitState::HalfOpen => {
120                let successes = self.success_count.fetch_add(1, Ordering::AcqRel) + 1;
121                if successes >= 2 {
122                    // Recovery successful → closed
123                    self.state
124                        .store(CircuitState::Closed as u8, Ordering::SeqCst);
125                    self.failure_count.store(0, Ordering::Release);
126                    tracing::info!("A2A circuit breaker CLOSED (recovery successful)");
127                }
128            }
129            CircuitState::Closed => {
130                // Reset failure count on success
131                self.failure_count.store(0, Ordering::Release);
132            }
133            CircuitState::Open => {}
134        }
135    }
136
137    /// Record a failed request.
138    pub fn record_failure(&self) {
139        let failures = self.failure_count.fetch_add(1, Ordering::AcqRel) + 1;
140        self.last_failure_time
141            .store(now_epoch_secs(), Ordering::Release);
142
143        if failures >= self.threshold && self.state() != CircuitState::Open {
144            self.state.store(CircuitState::Open as u8, Ordering::SeqCst);
145            tracing::warn!(
146                failures,
147                threshold = self.threshold,
148                "A2A circuit breaker OPEN"
149            );
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_initial_state_is_closed() {
160        let cb = A2ACircuitBreaker::new(3, 10);
161        assert_eq!(cb.state(), CircuitState::Closed);
162        assert!(cb.is_allowed());
163    }
164
165    #[test]
166    fn test_opens_after_threshold() {
167        let cb = A2ACircuitBreaker::new(3, 10);
168
169        cb.record_failure();
170        assert_eq!(cb.state(), CircuitState::Closed);
171
172        cb.record_failure();
173        assert_eq!(cb.state(), CircuitState::Closed);
174
175        cb.record_failure(); // Now at threshold
176        assert_eq!(cb.state(), CircuitState::Open);
177        assert!(!cb.is_allowed());
178    }
179
180    #[test]
181    fn test_success_resets_failure_count() {
182        let cb = A2ACircuitBreaker::new(3, 10);
183
184        cb.record_failure();
185        cb.record_failure();
186        cb.record_success(); // Should reset
187
188        assert_eq!(cb.failure_count.load(Ordering::Relaxed), 0);
189    }
190}