Skip to main content

oxios_kernel/resilience/
health.rs

1//! Per-provider circuit breaker — `ProviderHealthRegistry` (RFC-029 §3.6).
2//!
3//! Unlike the existing global `LLM_CIRCUIT_BREAKER` (which only records
4//! metrics, never gates), this registry tracks each provider's health
5//! independently. When a provider trips (e.g. 5 consecutive failures),
6//! the recovery coordinator skips every model on that provider in the
7//! fallback chain and jumps to the next healthy one.
8//!
9//! Half-open semantics: after `reset_after` seconds, one test request is
10//! allowed. If it succeeds, the provider returns to healthy; if it fails,
11//! the timeout resets.
12
13use parking_lot::RwLock;
14use std::collections::HashMap;
15use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
16
17/// Configuration for per-provider circuit breakers.
18#[derive(Debug, Clone)]
19pub struct BreakerConfig {
20    /// Number of consecutive failures before the breaker opens.
21    pub failure_threshold: u32,
22    /// Seconds to wait before half-opening (allowing a test request).
23    pub reset_after_secs: u64,
24}
25
26impl Default for BreakerConfig {
27    fn default() -> Self {
28        Self {
29            failure_threshold: 5,
30            reset_after_secs: 60,
31        }
32    }
33}
34
35/// State tracked for a single provider.
36#[derive(Debug)]
37struct ProviderState {
38    failures: AtomicU32,
39    /// Timestamp of the first failure in the current window (ms since epoch).
40    first_failure_at: AtomicU64,
41    /// Timestamp of when the breaker opened (ms since epoch). 0 = closed.
42    opened_at: AtomicU64,
43}
44
45impl ProviderState {
46    fn new() -> Self {
47        Self {
48            failures: AtomicU32::new(0),
49            first_failure_at: AtomicU64::new(0),
50            opened_at: AtomicU64::new(0),
51        }
52    }
53}
54
55/// Health registry keyed by provider name ("anthropic", "openai", …).
56///
57/// Each provider has its own circuit breaker so a failing provider can
58/// be skipped independently while healthy ones keep serving.
59pub struct ProviderHealthRegistry {
60    states: RwLock<HashMap<String, ProviderState>>,
61    config: BreakerConfig,
62}
63
64impl ProviderHealthRegistry {
65    /// Create a registry with the given breaker config.
66    pub fn new(config: BreakerConfig) -> Self {
67        Self {
68            states: RwLock::new(HashMap::new()),
69            config,
70        }
71    }
72
73    /// Whether the provider should be attempted.
74    ///
75    /// Returns `true` when:
76    /// - The breaker is closed (healthy).
77    /// - The breaker is half-open and the reset timer has elapsed
78    ///   (one test request allowed).
79    pub fn is_healthy(&self, provider: &str) -> bool {
80        let states = self.states.read();
81        let state = match states.get(provider) {
82            Some(s) => s,
83            None => return true, // never seen = healthy
84        };
85        let opened = state.opened_at.load(Ordering::SeqCst);
86        if opened == 0 {
87            return true; // closed
88        }
89        // Half-open: allow one test request after the timeout.
90        let elapsed = now_ms().saturating_sub(opened);
91        elapsed >= self.config.reset_after_secs * 1000
92    }
93
94    /// Record a successful request — resets the failure counter and
95    /// closes the breaker if it was half-open.
96    pub fn record_success(&self, provider: &str) {
97        let mut states = self.states.write();
98        let state = states
99            .entry(provider.to_string())
100            .or_insert_with(ProviderState::new);
101        state.failures.store(0, Ordering::SeqCst);
102        state.first_failure_at.store(0, Ordering::SeqCst);
103        state.opened_at.store(0, Ordering::SeqCst);
104    }
105
106    /// Record a failed request. If the consecutive failure count exceeds
107    /// the threshold, the breaker opens.
108    pub fn record_failure(&self, provider: &str) {
109        let mut states = self.states.write();
110        let state = states
111            .entry(provider.to_string())
112            .or_insert_with(ProviderState::new);
113        let n = state.failures.fetch_add(1, Ordering::SeqCst) + 1;
114        // Set first-failure timestamp on the first failure in a window.
115        let _ = state.first_failure_at.compare_exchange(
116            0,
117            now_ms(),
118            Ordering::SeqCst,
119            Ordering::SeqCst,
120        );
121        if n >= self.config.failure_threshold {
122            // Open: record when it opened, reset first_failure.
123            state.opened_at.store(now_ms(), Ordering::SeqCst);
124            state.first_failure_at.store(0, Ordering::SeqCst);
125        }
126    }
127}
128
129fn now_ms() -> u64 {
130    // Fast: SystemTime epoch millis is ~1μs on macOS/arm64.
131    use std::time::SystemTime;
132    SystemTime::now()
133        .duration_since(SystemTime::UNIX_EPOCH)
134        .map(|d| d.as_millis() as u64)
135        .unwrap_or(0)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn healthy_by_default() {
144        let reg = ProviderHealthRegistry::new(BreakerConfig::default());
145        assert!(reg.is_healthy("unknown-provider"));
146    }
147
148    #[test]
149    fn opens_after_threshold_failures() {
150        let reg = ProviderHealthRegistry::new(BreakerConfig {
151            failure_threshold: 3,
152            reset_after_secs: 60,
153        });
154        reg.record_failure("p");
155        reg.record_failure("p");
156        // Below threshold: still healthy.
157        assert!(reg.is_healthy("p"));
158        reg.record_failure("p");
159        // Threshold hit: now unhealthy.
160        assert!(!reg.is_healthy("p"));
161    }
162
163    #[test]
164    fn success_resets_failures() {
165        let reg = ProviderHealthRegistry::new(BreakerConfig {
166            failure_threshold: 3,
167            reset_after_secs: 3600, // won't reset in test
168        });
169        reg.record_failure("p");
170        reg.record_failure("p");
171        reg.record_success("p");
172        // Reset to zero, so one failure isn't enough.
173        reg.record_failure("p");
174        assert!(reg.is_healthy("p"));
175    }
176}