Skip to main content

oxicode_ai/
circuit_breaker.rs

1//! Circuit-breaker behavior trait + reference implementation.
2//!
3//! SDK owns the `CircuitBreaker` trait + `DefaultCircuitBreaker` (this
4//! module). Consumers implement `CircuitBreaker` for domain-specific traffic
5//! classes (A2A, HTTP, LLM calls) where the SDK's reference thresholds do not
6//! match the consumer's profile.
7//!
8//! See `docs/oxicode-sdk-ownership.md` §3 for the ownership contract and the
9//! reference pattern.
10//!
11//! # State machine
12//!
13//! ```text
14//!   Closed --failures >= threshold--> Open
15//!      ^                                 |
16//!      |                             reset_timeout
17//!      |                                 v
18//!      +---success---- HalfOpen <-- first check()
19//! ```
20//!
21//! - **Closed**: every `CircuitBreaker::check` returns `Ok`.
22//!   `CircuitBreaker::record_failure` increments the failure count; when it
23//!   reaches the threshold the breaker trips to `Open`.
24//! - **Open**: `CircuitBreaker::check` returns `Err(BreakerError::Open)` until
25//!   `reset_timeout` has elapsed since the last failure.
26//! - **HalfOpen**: the first `CircuitBreaker::check` after the reset timeout
27//!   returns `Ok` (a trial call). A success closes the breaker; a failure
28//!   re-opens it.
29
30use std::sync::Arc;
31use std::sync::atomic::{AtomicU8, AtomicU32, Ordering};
32use std::time::{Duration, Instant};
33
34/// Behavior contract for circuit-breaking resilience.
35///
36/// SDK owns this trait + [`DefaultCircuitBreaker`]; consumers implement it for
37/// domain-specific traffic classes (A2A, HTTP, etc.). See
38/// `docs/oxicode-sdk-ownership.md`.
39///
40/// This trait is `#[unstable]` initially — the surface may evolve as we
41/// integrate with the agent loop and learn which signals consumers actually
42/// need. It graduates to `#[stable]` after it proves useful in production.
43pub trait CircuitBreaker: Send + Sync {
44    /// Returns `Err(BreakerError::Open)` if the circuit is open (calls should
45    /// fail-fast). The check is cheap (atomic load) so callers may invoke it
46    /// before every retry.
47    fn check(&self) -> Result<(), BreakerError>;
48
49    /// Record a successful call. Resets the failure count and, if the breaker
50    /// was in `HalfOpen`, returns it to `Closed`.
51    fn record_success(&self);
52
53    /// Record a failed call. Increments the failure count; if the count
54    /// reaches the configured threshold the breaker trips to `Open`.
55    fn record_failure(&self);
56}
57
58/// Error returned when the circuit is open.
59///
60/// `#[non_exhaustive]` — consumers MUST add a catch-all arm. New variants
61/// will be added in future minor releases (e.g. `HalfOpen` or `Forced`) to
62/// surface state transitions that consumers may want to react to.
63///
64/// **A circuit-open error is NOT retryable.** A breaker's whole purpose is
65/// to STOP hammering a failing upstream. Callers that receive [`BreakerError`]
66/// MUST short-circuit the retry loop and return the error to the user. The
67/// [`BreakerError::is_retryable`] method exists to make this contract
68/// machine-checkable: integrations that map breaker errors into a provider
69/// retry loop should branch on it.
70#[derive(Debug, Clone, thiserror::Error)]
71#[non_exhaustive]
72pub enum BreakerError {
73    /// The circuit is open: too many consecutive failures, or the reset
74    /// timeout has not yet elapsed since the last failure.
75    #[error("circuit open: too many consecutive failures")]
76    Open,
77}
78
79impl BreakerError {
80    /// Returns `false`. A circuit-open error is a deliberate stop signal;
81    /// retrying would burn the retry budget against a known-open circuit and
82    /// is the exact behavior the breaker exists to prevent.
83    pub fn is_retryable(&self) -> bool {
84        false
85    }
86}
87
88// Internal state representation. Stored as the raw byte in the AtomicU8
89// so the state itself can be `Arc<DefaultCircuitBreaker>` without a Mutex.
90const STATE_CLOSED: u8 = 0;
91const STATE_OPEN: u8 = 1;
92const STATE_HALF_OPEN: u8 = 2;
93
94/// Observable breaker state. Returned by [`DefaultCircuitBreaker::state`].
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum BreakerState {
97    /// Breaker is closed — every call passes through.
98    Closed,
99    /// Breaker is open — calls fail-fast with [`BreakerError::Open`].
100    Open,
101    /// Breaker is half-open — the reset timeout has elapsed, and the next
102    /// call is a trial. Success closes the breaker, failure re-opens it.
103    HalfOpen,
104}
105
106/// SDK reference implementation: threshold-based with half-open state machine.
107///
108/// The thresholds (`failure_threshold`, `reset_timeout`) are SDK-illustrative,
109/// not consumer policy. A consumer whose traffic profile differs (e.g. A2A
110/// with `12 failures/min` and a 30s recovery window) implements
111/// [`CircuitBreaker`] for its own struct rather than reusing this.
112pub struct DefaultCircuitBreaker {
113    failure_threshold: u32,
114    reset_timeout: Duration,
115    created_at: Instant,
116    state: AtomicU8,
117    failure_count: AtomicU32,
118    last_failure_ms: AtomicU32,
119}
120
121impl DefaultCircuitBreaker {
122    /// Construct a new breaker. `failure_threshold` is the number of
123    /// consecutive failures that trip the breaker; `reset_timeout` is how
124    /// long the breaker stays open before allowing a trial call (half-open).
125    pub fn new(failure_threshold: u32, reset_timeout: Duration) -> Self {
126        Self {
127            failure_threshold,
128            reset_timeout,
129            created_at: Instant::now(),
130            state: AtomicU8::new(STATE_CLOSED),
131            failure_count: AtomicU32::new(0),
132            last_failure_ms: AtomicU32::new(0),
133        }
134    }
135
136    /// Current state (testing/observability).
137    pub fn state(&self) -> BreakerState {
138        match self.state.load(Ordering::Acquire) {
139            STATE_CLOSED => BreakerState::Closed,
140            STATE_OPEN => BreakerState::Open,
141            STATE_HALF_OPEN => BreakerState::HalfOpen,
142            _ => BreakerState::Closed,
143        }
144    }
145
146    /// Failure count since the last successful call (observability).
147    pub fn failure_count(&self) -> u32 {
148        self.failure_count.load(Ordering::Acquire)
149    }
150}
151
152impl CircuitBreaker for DefaultCircuitBreaker {
153    fn check(&self) -> Result<(), BreakerError> {
154        let state = self.state.load(Ordering::Acquire);
155        match state {
156            STATE_CLOSED | STATE_HALF_OPEN => Ok(()),
157            STATE_OPEN => {
158                let last_ms = self.last_failure_ms.load(Ordering::Acquire);
159                let elapsed_ms = self.created_at.elapsed().as_millis() as u64;
160                if elapsed_ms.saturating_sub(u64::from(last_ms))
161                    >= self.reset_timeout.as_millis() as u64
162                {
163                    // Reset timeout elapsed: allow one trial call.
164                    self.state.store(STATE_HALF_OPEN, Ordering::Release);
165                    Ok(())
166                } else {
167                    Err(BreakerError::Open)
168                }
169            }
170            _ => Ok(()),
171        }
172    }
173
174    fn record_success(&self) {
175        self.failure_count.store(0, Ordering::Release);
176        self.state.store(STATE_CLOSED, Ordering::Release);
177    }
178
179    fn record_failure(&self) {
180        let count = self.failure_count.fetch_add(1, Ordering::AcqRel) + 1;
181        self.last_failure_ms.store(
182            self.created_at.elapsed().as_millis() as u32,
183            Ordering::Release,
184        );
185        if count >= self.failure_threshold {
186            self.state.store(STATE_OPEN, Ordering::Release);
187        }
188    }
189}
190
191/// Shared ownership helper for `AgentLoopConfig.circuit_breaker`.
192pub type SharedBreaker = Arc<dyn CircuitBreaker>;
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn breaker_starts_closed_allows_calls() {
200        let b = DefaultCircuitBreaker::new(3, Duration::from_secs(30));
201        assert_eq!(b.state(), BreakerState::Closed);
202        assert!(b.check().is_ok());
203        assert_eq!(b.failure_count(), 0);
204    }
205
206    #[test]
207    fn breaker_opens_after_threshold_failures() {
208        let b = DefaultCircuitBreaker::new(3, Duration::from_secs(30));
209        b.record_failure();
210        b.record_failure();
211        assert!(b.check().is_ok(), "2 < 3, still closed");
212        b.record_failure();
213        assert_eq!(b.state(), BreakerState::Open);
214        assert!(b.check().is_err(), "3 >= 3, now open");
215    }
216
217    #[test]
218    fn breaker_half_opens_after_timeout() {
219        let b = DefaultCircuitBreaker::new(1, Duration::from_millis(20));
220        b.record_failure();
221        assert_eq!(b.state(), BreakerState::Open);
222        assert!(b.check().is_err(), "still open immediately after trip");
223        std::thread::sleep(Duration::from_millis(30));
224        assert!(b.check().is_ok(), "half-open allows trial after timeout");
225        assert_eq!(b.state(), BreakerState::HalfOpen);
226        b.record_success();
227        assert_eq!(b.state(), BreakerState::Closed);
228    }
229
230    #[test]
231    fn success_resets_failure_count() {
232        let b = DefaultCircuitBreaker::new(3, Duration::from_secs(30));
233        b.record_failure();
234        b.record_failure();
235        b.record_success();
236        b.record_failure();
237        b.record_failure();
238        assert!(b.check().is_ok(), "only 2 since reset, still closed");
239        assert_eq!(b.failure_count(), 2);
240    }
241
242    #[test]
243    fn trait_object_dispatch_works() {
244        let b: SharedBreaker = Arc::new(DefaultCircuitBreaker::new(2, Duration::from_secs(1)));
245        b.record_failure();
246        b.record_failure();
247        assert!(b.check().is_err());
248    }
249
250    #[test]
251    fn open_error_is_not_retryable() {
252        // The whole point: callers that receive this error MUST NOT retry.
253        let err = BreakerError::Open;
254        assert!(!err.is_retryable());
255    }
256}