vantage_api_pool/resilient/
breaker.rs1use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum Gate {
22 Allow { probe: Option<u64> },
26 OpenFor(Duration),
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BreakerState {
34 Closed,
36 Open { until: Instant },
38 HalfOpen,
40}
41
42#[derive(Default)]
43struct Inner {
44 consecutive_failures: usize,
45 open_until: Option<Instant>,
46 cooldown: Duration,
48 probing: Option<u64>,
53 next_epoch: u64,
55}
56
57pub(crate) struct CircuitBreaker {
58 threshold: usize,
59 base_cooldown: Duration,
60 max_cooldown: Duration,
61 inner: Mutex<Inner>,
62}
63
64const PROBE_POLL: Duration = Duration::from_millis(5);
66
67impl CircuitBreaker {
68 pub(crate) fn new(threshold: usize, base_cooldown: Duration, max_cooldown: Duration) -> Self {
69 Self {
70 threshold: threshold.max(1),
71 base_cooldown,
72 max_cooldown: max_cooldown.max(base_cooldown),
73 inner: Mutex::new(Inner {
74 cooldown: base_cooldown,
75 ..Inner::default()
76 }),
77 }
78 }
79
80 pub(crate) fn gate(&self) -> Gate {
81 let mut s = self.inner.lock().unwrap();
82 match s.open_until {
83 None => Gate::Allow { probe: None },
84 Some(until) => {
85 let now = Instant::now();
86 if now < until {
87 return Gate::OpenFor(until - now);
88 }
89 if s.probing.is_some() {
90 return Gate::OpenFor(PROBE_POLL);
91 }
92 let epoch = s.next_epoch;
93 s.next_epoch = s.next_epoch.wrapping_add(1);
94 s.probing = Some(epoch);
95 Gate::Allow { probe: Some(epoch) }
96 }
97 }
98 }
99
100 pub(crate) fn state(&self) -> BreakerState {
101 let s = self.inner.lock().unwrap();
102 match s.open_until {
103 None => BreakerState::Closed,
104 Some(until) if Instant::now() < until => BreakerState::Open { until },
105 Some(_) => BreakerState::HalfOpen,
106 }
107 }
108
109 pub(crate) fn release_probe(&self, epoch: u64) {
115 let mut s = self.inner.lock().unwrap();
116 if s.probing == Some(epoch) {
117 s.probing = None;
118 }
119 }
120
121 pub(crate) fn record_success(&self) -> bool {
124 let mut s = self.inner.lock().unwrap();
125 let was_open = s.open_until.is_some();
126 s.consecutive_failures = 0;
127 s.open_until = None;
128 s.probing = None;
129 s.cooldown = self.base_cooldown;
130 was_open
131 }
132
133 pub(crate) fn record_reachable(&self) -> bool {
139 let mut s = self.inner.lock().unwrap();
140 let was_open = s.open_until.is_some();
141 s.open_until = None;
142 s.probing = None;
143 s.cooldown = self.base_cooldown;
144 was_open
145 }
146
147 pub(crate) fn record_failure(&self, probe: Option<u64>) -> Option<Duration> {
155 let mut s = self.inner.lock().unwrap();
156 s.consecutive_failures += 1;
157 if probe.is_some() && probe == s.probing {
158 s.probing = None;
160 s.cooldown = s.cooldown.saturating_mul(2).min(self.max_cooldown);
161 let cooldown = s.cooldown;
162 s.open_until = Some(deadline(cooldown));
163 return Some(cooldown);
164 }
165 if s.open_until.is_none() && s.consecutive_failures >= self.threshold {
166 let cooldown = s.cooldown;
167 s.open_until = Some(deadline(cooldown));
168 return Some(cooldown);
169 }
170 None
171 }
172}
173
174fn deadline(cooldown: Duration) -> Instant {
177 let now = Instant::now();
178 now.checked_add(cooldown).unwrap_or(now)
179}
180
181pub(crate) struct ProbeGuard {
186 breaker: Arc<CircuitBreaker>,
187 epoch: u64,
188}
189
190impl ProbeGuard {
191 pub(crate) fn new(breaker: Arc<CircuitBreaker>, epoch: u64) -> Self {
192 Self { breaker, epoch }
193 }
194
195 pub(crate) fn epoch(&self) -> u64 {
197 self.epoch
198 }
199}
200
201impl Drop for ProbeGuard {
202 fn drop(&mut self) {
203 self.breaker.release_probe(self.epoch);
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn probe_epoch(gate: Gate) -> u64 {
213 match gate {
214 Gate::Allow { probe: Some(e) } => e,
215 other => panic!("expected a probe grant, got {other:?}"),
216 }
217 }
218
219 #[test]
220 fn opens_after_threshold_and_doubles_on_failed_probe() {
221 let b = CircuitBreaker::new(2, Duration::from_millis(10), Duration::from_millis(25));
222 assert_eq!(b.gate(), Gate::Allow { probe: None });
223 assert_eq!(b.record_failure(None), None);
224 assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
225 assert!(matches!(b.gate(), Gate::OpenFor(_)));
226 std::thread::sleep(Duration::from_millis(12));
227 let first = probe_epoch(b.gate());
228 assert_eq!(b.gate(), Gate::OpenFor(PROBE_POLL), "one probe at a time");
229 assert_eq!(
230 b.record_failure(Some(first)),
231 Some(Duration::from_millis(20))
232 );
233 std::thread::sleep(Duration::from_millis(22));
234 let second = probe_epoch(b.gate());
235 assert_ne!(second, first, "each grant gets a fresh epoch");
236 assert_eq!(
237 b.record_failure(Some(second)),
238 Some(Duration::from_millis(25)),
239 "capped"
240 );
241 }
242
243 #[test]
244 fn success_closes_and_resets() {
245 let b = CircuitBreaker::new(1, Duration::from_millis(10), Duration::from_millis(40));
246 assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
247 std::thread::sleep(Duration::from_millis(12));
248 let epoch = probe_epoch(b.gate());
249 assert_eq!(
250 b.record_failure(Some(epoch)),
251 Some(Duration::from_millis(20))
252 );
253 std::thread::sleep(Duration::from_millis(22));
254 probe_epoch(b.gate());
255 assert!(b.record_success());
256 assert_eq!(
257 b.record_failure(None),
258 Some(Duration::from_millis(10)),
259 "back to base"
260 );
261 }
262
263 #[test]
264 fn reachable_closes_without_clearing_the_failure_run() {
265 let b = CircuitBreaker::new(3, Duration::from_millis(10), Duration::from_millis(10));
266 assert_eq!(b.record_failure(None), None);
269 assert!(!b.record_reachable(), "nothing was open to close");
270 assert_eq!(b.record_failure(None), None);
271 assert!(!b.record_reachable());
272 assert_eq!(
273 b.record_failure(None),
274 Some(Duration::from_millis(10)),
275 "the third 5xx reaches the threshold"
276 );
277 assert!(b.record_reachable(), "a 4xx closes an open breaker");
278 assert_eq!(b.state(), BreakerState::Closed);
279 }
280
281 #[test]
282 fn state_moves_closed_open_half_open() {
283 let b = CircuitBreaker::new(1, Duration::from_millis(10), Duration::from_millis(10));
284 assert_eq!(b.state(), BreakerState::Closed);
285 b.record_failure(None);
286 assert!(matches!(b.state(), BreakerState::Open { .. }));
287 std::thread::sleep(Duration::from_millis(12));
288 assert_eq!(b.state(), BreakerState::HalfOpen);
289 assert!(b.record_success());
290 assert_eq!(b.state(), BreakerState::Closed);
291 }
292
293 #[test]
294 fn dropped_probe_guard_releases_the_slot() {
295 let b = Arc::new(CircuitBreaker::new(
296 1,
297 Duration::from_millis(10),
298 Duration::from_millis(10),
299 ));
300 assert_eq!(b.record_failure(None), Some(Duration::from_millis(10)));
301 std::thread::sleep(Duration::from_millis(12));
302 let epoch = probe_epoch(b.gate());
303 let guard = ProbeGuard::new(Arc::clone(&b), epoch);
304 assert_eq!(
305 b.gate(),
306 Gate::OpenFor(PROBE_POLL),
307 "the probe is in flight"
308 );
309 drop(guard);
310 assert!(
311 matches!(b.gate(), Gate::Allow { probe: Some(_) }),
312 "dropping the guard without recording an outcome frees the slot"
313 );
314 }
315
316 #[test]
317 fn a_stale_guard_does_not_free_the_current_probe() {
318 let b = Arc::new(CircuitBreaker::new(
319 1,
320 Duration::from_millis(10),
321 Duration::from_millis(10),
322 ));
323 b.record_failure(None);
324 std::thread::sleep(Duration::from_millis(12));
325 let stale = ProbeGuard::new(Arc::clone(&b), probe_epoch(b.gate()));
326 b.record_failure(Some(stale.epoch()));
329 std::thread::sleep(Duration::from_millis(22));
330 let fresh = probe_epoch(b.gate());
331 drop(stale);
332 assert_eq!(
333 b.gate(),
334 Gate::OpenFor(PROBE_POLL),
335 "the fresh grant still holds the slot"
336 );
337 assert!(b.record_failure(Some(fresh)).is_some());
338 }
339
340 #[test]
341 fn a_failure_outside_the_probe_does_not_double_the_cooldown() {
342 let b = Arc::new(CircuitBreaker::new(
343 1,
344 Duration::from_millis(10),
345 Duration::from_millis(80),
346 ));
347 b.record_failure(None);
348 std::thread::sleep(Duration::from_millis(12));
349 let epoch = probe_epoch(b.gate());
350 assert_eq!(b.record_failure(None), None, "the breaker is already open");
352 assert_eq!(
353 b.record_failure(Some(epoch)),
354 Some(Duration::from_millis(20)),
355 "only the probe's own failure doubles the cooldown"
356 );
357 }
358}