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