Skip to main content

runifold_model/
circuit.rs

1use std::{
2    sync::{Arc, Mutex},
3    time::{Duration, Instant},
4};
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::{ModelError, ModelErrorKind, ModelRef};
10
11/// Clock used by model-routing resilience policy.
12///
13/// Applications may inject a deterministic implementation for tests.
14pub trait RouterClock: Send + Sync {
15    /// Returns the current monotonic time.
16    fn now(&self) -> Instant;
17}
18
19/// Monotonic system clock.
20#[derive(Clone, Copy, Debug, Default)]
21pub struct SystemRouterClock;
22
23impl RouterClock for SystemRouterClock {
24    fn now(&self) -> Instant {
25        Instant::now()
26    }
27}
28
29/// Invalid circuit-breaker configuration.
30#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum CircuitBreakerConfigError {
33    /// A circuit could never open.
34    #[error("circuit-breaker failure threshold must be greater than zero")]
35    ZeroFailureThreshold,
36    /// An open circuit would immediately expire.
37    #[error("circuit-breaker cooldown must be greater than zero")]
38    ZeroCooldown,
39}
40
41/// Per-route circuit-breaker policy.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct CircuitBreakerConfig {
44    failure_threshold: u32,
45    cooldown: Duration,
46    counted_kinds: Vec<ModelErrorKind>,
47}
48
49impl CircuitBreakerConfig {
50    /// Creates a breaker that counts transport, provider, protocol, and stream
51    /// state failures.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`CircuitBreakerConfigError`] when the threshold or cooldown is
56    /// zero.
57    pub fn new(
58        failure_threshold: u32,
59        cooldown: Duration,
60    ) -> Result<Self, CircuitBreakerConfigError> {
61        if failure_threshold == 0 {
62            return Err(CircuitBreakerConfigError::ZeroFailureThreshold);
63        }
64        if cooldown.is_zero() {
65            return Err(CircuitBreakerConfigError::ZeroCooldown);
66        }
67        Ok(Self {
68            failure_threshold,
69            cooldown,
70            counted_kinds: vec![
71                ModelErrorKind::Transport,
72                ModelErrorKind::Provider,
73                ModelErrorKind::Protocol,
74                ModelErrorKind::StreamState,
75            ],
76        })
77    }
78
79    /// Replaces the failure kinds counted by this breaker.
80    #[must_use]
81    pub fn counted_kinds(mut self, kinds: impl IntoIterator<Item = ModelErrorKind>) -> Self {
82        self.counted_kinds.clear();
83        for kind in kinds {
84            if !self.counted_kinds.contains(&kind) {
85                self.counted_kinds.push(kind);
86            }
87        }
88        self
89    }
90
91    /// Returns the consecutive counted-failure threshold.
92    pub const fn failure_threshold(&self) -> u32 {
93        self.failure_threshold
94    }
95
96    /// Returns how long an opened route remains unavailable before probing.
97    pub const fn cooldown(&self) -> Duration {
98        self.cooldown
99    }
100
101    /// Returns error kinds that contribute to opening the circuit.
102    pub fn failure_kinds(&self) -> &[ModelErrorKind] {
103        &self.counted_kinds
104    }
105
106    pub(crate) fn counts(&self, error: &ModelError) -> bool {
107        error.kind != ModelErrorKind::Cancelled && self.counted_kinds.contains(&error.kind)
108    }
109}
110
111/// Public route-health state.
112#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
113#[serde(rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum CircuitState {
116    /// Requests may use the route.
117    Closed,
118    /// Requests skip the route during its cooldown.
119    Open,
120    /// Exactly one recovery probe is currently using the route.
121    HalfOpen,
122}
123
124/// Point-in-time health for one physical model route.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct ModelRouteHealth {
127    /// Stable route name.
128    pub route: String,
129    /// Physical model target.
130    pub target: ModelRef,
131    /// Current circuit state.
132    pub state: CircuitState,
133    /// Consecutive counted failures in the current generation.
134    pub consecutive_failures: u32,
135    /// Remaining cooldown for an open route.
136    pub retry_after: Option<Duration>,
137}
138
139#[derive(Debug)]
140pub(crate) struct BreakerState {
141    generation: u64,
142    phase: BreakerPhase,
143}
144
145#[derive(Debug)]
146enum BreakerPhase {
147    Closed { failures: u32 },
148    Open { until: Option<Instant> },
149    HalfOpen,
150}
151
152impl Default for BreakerState {
153    fn default() -> Self {
154        Self {
155            generation: 0,
156            phase: BreakerPhase::Closed { failures: 0 },
157        }
158    }
159}
160
161pub(crate) type SharedBreakerState = Arc<Mutex<BreakerState>>;
162
163pub(crate) enum RoutePermit {
164    Disabled,
165    Acquired(BreakerPermit),
166    Rejected,
167}
168
169pub(crate) struct BreakerPermit {
170    state: SharedBreakerState,
171    config: CircuitBreakerConfig,
172    clock: Arc<dyn RouterClock>,
173    generation: u64,
174    probe: bool,
175    resolved: bool,
176}
177
178impl BreakerPermit {
179    pub(crate) const fn is_probe(&self) -> bool {
180        self.probe
181    }
182
183    pub(crate) fn success(mut self) {
184        let mut state = lock(&self.state);
185        if state.generation == self.generation {
186            state.generation = state.generation.wrapping_add(1);
187            state.phase = BreakerPhase::Closed { failures: 0 };
188        }
189        self.resolved = true;
190    }
191
192    pub(crate) fn failure(mut self, error: &ModelError) {
193        if self.config.counts(error) {
194            record_counted_failure(
195                &self.state,
196                &self.config,
197                self.clock.now(),
198                self.generation,
199                self.probe,
200            );
201        } else if self.probe {
202            reopen(
203                &self.state,
204                self.clock.now(),
205                self.config.cooldown,
206                self.generation,
207            );
208        }
209        self.resolved = true;
210    }
211}
212
213impl Drop for BreakerPermit {
214    fn drop(&mut self) {
215        if self.probe && !self.resolved {
216            reopen(
217                &self.state,
218                self.clock.now(),
219                self.config.cooldown,
220                self.generation,
221            );
222        }
223    }
224}
225
226pub(crate) fn acquire(
227    state: &SharedBreakerState,
228    config: Option<&CircuitBreakerConfig>,
229    clock: &Arc<dyn RouterClock>,
230) -> RoutePermit {
231    let Some(config) = config else {
232        return RoutePermit::Disabled;
233    };
234    let now = clock.now();
235    let mut state_guard = lock(state);
236    let generation = state_guard.generation;
237    let probe = match state_guard.phase {
238        BreakerPhase::Closed { .. } => false,
239        BreakerPhase::Open { until: Some(until) } if now >= until => {
240            state_guard.phase = BreakerPhase::HalfOpen;
241            true
242        }
243        BreakerPhase::Open { .. } | BreakerPhase::HalfOpen => return RoutePermit::Rejected,
244    };
245    drop(state_guard);
246    RoutePermit::Acquired(BreakerPermit {
247        state: state.clone(),
248        config: config.clone(),
249        clock: clock.clone(),
250        generation,
251        probe,
252        resolved: false,
253    })
254}
255
256pub(crate) fn snapshot(
257    state: &SharedBreakerState,
258    route: String,
259    target: ModelRef,
260    config: Option<&CircuitBreakerConfig>,
261    now: Instant,
262) -> ModelRouteHealth {
263    let state = lock(state);
264    let (health, failures, retry_after) = match state.phase {
265        BreakerPhase::Closed { failures } => (CircuitState::Closed, failures, None),
266        BreakerPhase::Open { until } => (
267            CircuitState::Open,
268            0,
269            until.map(|until| until.saturating_duration_since(now)),
270        ),
271        BreakerPhase::HalfOpen => (CircuitState::HalfOpen, 0, None),
272    };
273    if config.is_none() {
274        return ModelRouteHealth {
275            route,
276            target,
277            state: CircuitState::Closed,
278            consecutive_failures: 0,
279            retry_after: None,
280        };
281    }
282    ModelRouteHealth {
283        route,
284        target,
285        state: health,
286        consecutive_failures: failures,
287        retry_after,
288    }
289}
290
291fn record_counted_failure(
292    state: &SharedBreakerState,
293    config: &CircuitBreakerConfig,
294    now: Instant,
295    generation: u64,
296    probe: bool,
297) {
298    let mut state = lock(state);
299    if state.generation != generation {
300        return;
301    }
302    if probe {
303        state.generation = state.generation.wrapping_add(1);
304        state.phase = BreakerPhase::Open {
305            until: now.checked_add(config.cooldown),
306        };
307        return;
308    }
309    let BreakerPhase::Closed { failures } = &mut state.phase else {
310        return;
311    };
312    *failures = failures.saturating_add(1);
313    if *failures >= config.failure_threshold {
314        state.generation = state.generation.wrapping_add(1);
315        state.phase = BreakerPhase::Open {
316            until: now.checked_add(config.cooldown),
317        };
318    }
319}
320
321fn reopen(state: &SharedBreakerState, now: Instant, cooldown: Duration, generation: u64) {
322    let mut state = lock(state);
323    if state.generation == generation {
324        state.generation = state.generation.wrapping_add(1);
325        state.phase = BreakerPhase::Open {
326            until: now.checked_add(cooldown),
327        };
328    }
329}
330
331fn lock(state: &SharedBreakerState) -> std::sync::MutexGuard<'_, BreakerState> {
332    state
333        .lock()
334        .unwrap_or_else(std::sync::PoisonError::into_inner)
335}
336
337#[cfg(test)]
338mod tests {
339    use std::{
340        sync::{Arc, Mutex},
341        time::{Duration, Instant},
342    };
343
344    use runifold_core::RetrySafety;
345
346    use crate::{ModelError, ModelErrorKind, ModelRef};
347
348    use super::{
349        BreakerState, CircuitBreakerConfig, CircuitState, RoutePermit, RouterClock,
350        SharedBreakerState, acquire, snapshot,
351    };
352
353    struct ManualClock {
354        now: Mutex<Instant>,
355    }
356
357    impl ManualClock {
358        fn new() -> Self {
359            Self {
360                now: Mutex::new(Instant::now()),
361            }
362        }
363
364        fn advance(&self, duration: Duration) {
365            let mut now = self
366                .now
367                .lock()
368                .unwrap_or_else(std::sync::PoisonError::into_inner);
369            *now += duration;
370        }
371    }
372
373    impl RouterClock for ManualClock {
374        fn now(&self) -> Instant {
375            *self
376                .now
377                .lock()
378                .unwrap_or_else(std::sync::PoisonError::into_inner)
379        }
380    }
381
382    fn state() -> SharedBreakerState {
383        Arc::new(Mutex::new(BreakerState::default()))
384    }
385
386    fn config(threshold: u32) -> CircuitBreakerConfig {
387        CircuitBreakerConfig::new(threshold, Duration::from_secs(10)).unwrap()
388    }
389
390    fn failure() -> ModelError {
391        let mut error = ModelError::local(ModelErrorKind::Transport, "failure");
392        error.retry_safety = RetrySafety::Safe;
393        error
394    }
395
396    fn permit(
397        state: &SharedBreakerState,
398        config: &CircuitBreakerConfig,
399        clock: &Arc<dyn RouterClock>,
400    ) -> super::BreakerPermit {
401        match acquire(state, Some(config), clock) {
402            RoutePermit::Acquired(permit) => permit,
403            RoutePermit::Disabled | RoutePermit::Rejected => panic!("expected route permit"),
404        }
405    }
406
407    fn health(
408        state: &SharedBreakerState,
409        config: &CircuitBreakerConfig,
410        clock: &Arc<dyn RouterClock>,
411    ) -> super::ModelRouteHealth {
412        snapshot(
413            state,
414            "route".into(),
415            ModelRef::new("test", "model"),
416            Some(config),
417            clock.now(),
418        )
419    }
420
421    #[test]
422    fn threshold_opens_then_one_successful_probe_closes() {
423        let state = state();
424        let clock_impl = Arc::new(ManualClock::new());
425        let clock: Arc<dyn RouterClock> = clock_impl.clone();
426        let config = config(2);
427
428        permit(&state, &config, &clock).failure(&failure());
429        assert_eq!(health(&state, &config, &clock).consecutive_failures, 1);
430        permit(&state, &config, &clock).failure(&failure());
431        assert_eq!(health(&state, &config, &clock).state, CircuitState::Open);
432        assert!(matches!(
433            acquire(&state, Some(&config), &clock),
434            RoutePermit::Rejected
435        ));
436
437        clock_impl.advance(config.cooldown());
438        let probe = permit(&state, &config, &clock);
439        assert_eq!(
440            health(&state, &config, &clock).state,
441            CircuitState::HalfOpen
442        );
443        assert!(matches!(
444            acquire(&state, Some(&config), &clock),
445            RoutePermit::Rejected
446        ));
447        probe.success();
448
449        let health = health(&state, &config, &clock);
450        assert_eq!(health.state, CircuitState::Closed);
451        assert_eq!(health.consecutive_failures, 0);
452    }
453
454    #[test]
455    fn stale_failure_cannot_overwrite_a_newer_success_generation() {
456        let state = state();
457        let clock_impl = Arc::new(ManualClock::new());
458        let clock: Arc<dyn RouterClock> = clock_impl.clone();
459        let config = config(1);
460        let delayed_permit = permit(&state, &config, &clock);
461        let opener = permit(&state, &config, &clock);
462
463        opener.failure(&failure());
464        clock_impl.advance(config.cooldown());
465        permit(&state, &config, &clock).success();
466        delayed_permit.failure(&failure());
467
468        assert_eq!(health(&state, &config, &clock).state, CircuitState::Closed);
469    }
470
471    #[test]
472    fn abandoned_half_open_probe_reopens_the_route() {
473        let state = state();
474        let clock_impl = Arc::new(ManualClock::new());
475        let clock: Arc<dyn RouterClock> = clock_impl.clone();
476        let config = config(1);
477
478        permit(&state, &config, &clock).failure(&failure());
479        clock_impl.advance(config.cooldown());
480        let probe = permit(&state, &config, &clock);
481        drop(probe);
482
483        let health = health(&state, &config, &clock);
484        assert_eq!(health.state, CircuitState::Open);
485        assert_eq!(health.retry_after, Some(config.cooldown()));
486    }
487
488    #[test]
489    fn non_counted_failure_does_not_damage_a_closed_route() {
490        let state = state();
491        let clock: Arc<dyn RouterClock> = Arc::new(ManualClock::new());
492        let config = config(1);
493        let error = ModelError::local(ModelErrorKind::InvalidRequest, "caller error");
494
495        permit(&state, &config, &clock).failure(&error);
496
497        let health = health(&state, &config, &clock);
498        assert_eq!(health.state, CircuitState::Closed);
499        assert_eq!(health.consecutive_failures, 0);
500    }
501}