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}