1use std::{
2 collections::HashMap,
3 sync::Mutex,
4 time::{Duration, Instant},
5};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CircuitState {
9 Closed,
10 Open,
11 HalfOpen,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CircuitDecision {
16 Allow { probe: bool },
17 Skip,
18}
19
20#[derive(Debug, Clone, Copy)]
21struct Circuit {
22 state: CircuitState,
23 failures: u32,
24 phase_started: Option<Instant>,
25}
26
27impl Default for Circuit {
28 fn default() -> Self {
29 Self {
30 state: CircuitState::Closed,
31 failures: 0,
32 phase_started: None,
33 }
34 }
35}
36
37pub struct CircuitBreaker {
38 threshold: u32,
39 cooldown: Duration,
40 circuits: Mutex<HashMap<String, Circuit>>,
41}
42
43impl CircuitBreaker {
44 pub fn new(threshold: u32, cooldown: Duration) -> Self {
45 Self {
46 threshold: threshold.max(1),
47 cooldown,
48 circuits: Mutex::new(HashMap::new()),
49 }
50 }
51
52 pub fn allow(&self, provider: &str) -> CircuitDecision {
53 self.allow_at(provider, Instant::now())
54 }
55
56 pub fn record_success(&self, provider: &str) {
57 let mut circuits = self.lock();
58 let circuit = circuits.entry(provider.to_owned()).or_default();
59 circuit.state = CircuitState::Closed;
60 circuit.failures = 0;
61 circuit.phase_started = None;
62 }
63
64 pub fn record_failure(&self, provider: &str) {
65 self.record_failure_at(provider, Instant::now());
66 }
67
68 pub fn state(&self, provider: &str) -> CircuitState {
69 self.lock()
70 .get(provider)
71 .map_or(CircuitState::Closed, |circuit| circuit.state)
72 }
73
74 pub fn snapshot(&self) -> Vec<(String, CircuitState)> {
75 self.lock()
76 .iter()
77 .map(|(provider, circuit)| (provider.clone(), circuit.state))
78 .collect()
79 }
80
81 pub fn observed(&self) -> Vec<(String, CircuitState)> {
95 self.observed_at(Instant::now())
96 }
97
98 fn observed_at(&self, now: Instant) -> Vec<(String, CircuitState)> {
99 self.lock()
100 .iter()
101 .map(|(provider, circuit)| {
102 let phase = match circuit.state {
103 CircuitState::Open | CircuitState::HalfOpen
104 if elapsed(circuit.phase_started, now) >= self.cooldown =>
105 {
106 CircuitState::HalfOpen
107 }
108 held => held,
109 };
110 (provider.clone(), phase)
111 })
112 .collect()
113 }
114
115 fn allow_at(&self, provider: &str, now: Instant) -> CircuitDecision {
116 let mut circuits = self.lock();
117 let circuit = circuits.entry(provider.to_owned()).or_default();
118 match circuit.state {
119 CircuitState::Closed => CircuitDecision::Allow { probe: false },
120 CircuitState::Open if elapsed(circuit.phase_started, now) >= self.cooldown => {
121 circuit.state = CircuitState::HalfOpen;
122 circuit.phase_started = Some(now);
123 CircuitDecision::Allow { probe: true }
124 }
125 CircuitState::HalfOpen if elapsed(circuit.phase_started, now) >= self.cooldown => {
126 circuit.phase_started = Some(now);
127 CircuitDecision::Allow { probe: true }
128 }
129 CircuitState::Open | CircuitState::HalfOpen => CircuitDecision::Skip,
130 }
131 }
132
133 fn record_failure_at(&self, provider: &str, now: Instant) {
134 let mut circuits = self.lock();
135 let circuit = circuits.entry(provider.to_owned()).or_default();
136 match circuit.state {
137 CircuitState::Closed => {
138 circuit.failures = circuit.failures.saturating_add(1);
139 if circuit.failures >= self.threshold {
140 circuit.state = CircuitState::Open;
141 circuit.phase_started = Some(now);
142 }
143 }
144 CircuitState::HalfOpen => {
145 circuit.state = CircuitState::Open;
146 circuit.failures = self.threshold;
147 circuit.phase_started = Some(now);
148 }
149 CircuitState::Open => {}
150 }
151 }
152
153 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Circuit>> {
154 self.circuits
155 .lock()
156 .unwrap_or_else(std::sync::PoisonError::into_inner)
157 }
158}
159
160fn elapsed(started: Option<Instant>, now: Instant) -> Duration {
161 started.map_or(Duration::MAX, |started| {
162 now.saturating_duration_since(started)
163 })
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn opens_skips_probes_and_recovers() {
172 let breaker = CircuitBreaker::new(2, Duration::from_secs(10));
173 let now = Instant::now();
174 breaker.record_failure_at("openai", now);
175 breaker.record_failure_at("openai", now);
176 assert_eq!(breaker.allow_at("openai", now), CircuitDecision::Skip);
177 assert_eq!(
178 breaker.allow_at("openai", now + Duration::from_secs(10)),
179 CircuitDecision::Allow { probe: true }
180 );
181 breaker.record_success("openai");
182 assert_eq!(breaker.state("openai"), CircuitState::Closed);
183 }
184
185 #[test]
186 fn an_elapsed_cooldown_is_visible_before_a_request_spends_it() {
187 let breaker = CircuitBreaker::new(2, Duration::from_secs(10));
188 let now = Instant::now();
189 breaker.record_failure_at("openai", now);
190 breaker.record_failure_at("openai", now);
191
192 assert_eq!(
193 breaker.observed_at(now),
194 vec![("openai".to_owned(), CircuitState::Open)],
195 "a target inside its cooldown is one this replica is refusing"
196 );
197
198 let recovered = now + Duration::from_secs(10);
199 assert_eq!(
200 breaker.observed_at(recovered),
201 vec![("openai".to_owned(), CircuitState::HalfOpen)],
202 "a target whose cooldown elapsed is one the next request would probe"
203 );
204 assert_eq!(
205 breaker.snapshot(),
206 vec![("openai".to_owned(), CircuitState::Open)],
207 "and looking at it moved nothing"
208 );
209 assert_eq!(
210 breaker.allow_at("openai", recovered),
211 CircuitDecision::Allow { probe: true },
212 "so the probe is still the next request's to spend"
213 );
214 }
215}