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
11pub trait RouterClock: Send + Sync {
15 fn now(&self) -> Instant;
17}
18
19#[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#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum CircuitBreakerConfigError {
33 #[error("circuit-breaker failure threshold must be greater than zero")]
35 ZeroFailureThreshold,
36 #[error("circuit-breaker cooldown must be greater than zero")]
38 ZeroCooldown,
39}
40
41#[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 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 #[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 pub const fn failure_threshold(&self) -> u32 {
93 self.failure_threshold
94 }
95
96 pub const fn cooldown(&self) -> Duration {
98 self.cooldown
99 }
100
101 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
113#[serde(rename_all = "snake_case")]
114#[non_exhaustive]
115pub enum CircuitState {
116 Closed,
118 Open,
120 HalfOpen,
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct ModelRouteHealth {
127 pub route: String,
129 pub target: ModelRef,
131 pub state: CircuitState,
133 pub consecutive_failures: u32,
135 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}