1use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
13use std::time::{Duration, SystemTime};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BreakerState {
19 Closed = 0,
21 Open = 1,
23 HalfOpen = 2,
25}
26
27impl BreakerState {
28 fn from_u8(v: u8) -> BreakerState {
29 match v {
30 1 => BreakerState::Open,
31 2 => BreakerState::HalfOpen,
32 _ => BreakerState::Closed,
33 }
34 }
35 pub fn as_str(self) -> &'static str {
37 match self {
38 BreakerState::Closed => "closed",
39 BreakerState::Open => "open",
40 BreakerState::HalfOpen => "half-open",
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ErrKind {
51 None = 0,
52 Refused = 1,
53 Reset = 2,
54 Timeout = 3,
55 Http5xx = 4,
56 Http429 = 5,
57 Probe = 6,
58}
59
60impl ErrKind {
61 fn from_u8(v: u8) -> ErrKind {
62 match v {
63 1 => ErrKind::Refused,
64 2 => ErrKind::Reset,
65 3 => ErrKind::Timeout,
66 4 => ErrKind::Http5xx,
67 5 => ErrKind::Http429,
68 6 => ErrKind::Probe,
69 _ => ErrKind::None,
70 }
71 }
72 pub fn as_str(self) -> &'static str {
73 match self {
74 ErrKind::None => "none",
75 ErrKind::Refused => "refused",
76 ErrKind::Reset => "reset",
77 ErrKind::Timeout => "timeout",
78 ErrKind::Http5xx => "5xx",
79 ErrKind::Http429 => "429",
80 ErrKind::Probe => "probe",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy)]
89pub struct BreakerConfig {
90 pub open_threshold: u32,
92 pub cooldown: Duration,
94 pub cooldown_max: Duration,
96}
97
98impl Default for BreakerConfig {
99 fn default() -> BreakerConfig {
100 BreakerConfig {
101 open_threshold: 3,
102 cooldown: Duration::from_secs(5),
103 cooldown_max: Duration::from_secs(60),
104 }
105 }
106}
107
108#[derive(Debug)]
113pub struct HealthRecord {
114 state: AtomicU8, consec_fail: AtomicU32, total_calls: AtomicU64,
117 total_fail: AtomicU64, ewma_latency_us: AtomicU64,
119 last_ok_unix_ms: AtomicU64,
120 last_err_unix_ms: AtomicU64,
121 last_err_kind: AtomicU8, opened_unix_ms: AtomicU64,
123 open_count: AtomicU32,
126}
127
128impl Default for HealthRecord {
129 fn default() -> HealthRecord {
130 HealthRecord::new()
131 }
132}
133
134impl HealthRecord {
135 pub const fn new() -> HealthRecord {
136 HealthRecord {
137 state: AtomicU8::new(BreakerState::Closed as u8),
138 consec_fail: AtomicU32::new(0),
139 total_calls: AtomicU64::new(0),
140 total_fail: AtomicU64::new(0),
141 ewma_latency_us: AtomicU64::new(0),
142 last_ok_unix_ms: AtomicU64::new(0),
143 last_err_unix_ms: AtomicU64::new(0),
144 last_err_kind: AtomicU8::new(ErrKind::None as u8),
145 opened_unix_ms: AtomicU64::new(0),
146 open_count: AtomicU32::new(0),
147 }
148 }
149
150 pub fn state(&self) -> BreakerState {
151 BreakerState::from_u8(self.state.load(Ordering::Relaxed))
152 }
153
154 pub fn consec_fail(&self) -> u32 {
155 self.consec_fail.load(Ordering::Relaxed)
156 }
157
158 pub fn total_calls(&self) -> u64 {
159 self.total_calls.load(Ordering::Relaxed)
160 }
161
162 pub fn total_fail(&self) -> u64 {
163 self.total_fail.load(Ordering::Relaxed)
164 }
165
166 pub fn error_rate(&self) -> f64 {
171 let calls = self.total_calls();
172 if calls == 0 {
173 0.0
174 } else {
175 self.total_fail() as f64 / calls as f64
176 }
177 }
178
179 pub fn ewma_latency_ms(&self) -> u64 {
180 self.ewma_latency_us.load(Ordering::Relaxed) / 1000
181 }
182
183 pub fn last_err_kind(&self) -> ErrKind {
184 ErrKind::from_u8(self.last_err_kind.load(Ordering::Relaxed))
185 }
186
187 pub fn last_ok_ms_ago(&self) -> Option<u64> {
188 let t = self.last_ok_unix_ms.load(Ordering::Relaxed);
189 if t == 0 {
190 None
191 } else {
192 Some(now_unix_ms().saturating_sub(t))
193 }
194 }
195
196 pub fn opened_ms_ago(&self) -> Option<u64> {
197 let t = self.opened_unix_ms.load(Ordering::Relaxed);
198 if t == 0 {
199 None
200 } else {
201 Some(now_unix_ms().saturating_sub(t))
202 }
203 }
204
205 pub fn cooldown(&self, cfg: &BreakerConfig) -> Duration {
210 let n = self.open_count.load(Ordering::Relaxed).saturating_sub(1);
211 let shift = n.min(20); let scaled = cfg.cooldown.saturating_mul(1u32 << shift);
213 scaled.min(cfg.cooldown_max)
214 }
215
216 pub fn available(&self, cfg: &BreakerConfig) -> bool {
222 match self.state() {
223 BreakerState::Closed | BreakerState::HalfOpen => true,
224 BreakerState::Open => {
225 if self.opened_ms_ago().unwrap_or(0) >= self.cooldown(cfg).as_millis() as u64 {
230 self.state
231 .store(BreakerState::HalfOpen as u8, Ordering::Relaxed);
232 true
233 } else {
234 false
235 }
236 }
237 }
238 }
239
240 pub fn is_up(&self) -> bool {
245 self.state() != BreakerState::Open
246 }
247
248 pub fn record_success(&self, latency: Duration) -> Option<BreakerTransition> {
254 self.total_calls.fetch_add(1, Ordering::Relaxed);
255 self.consec_fail.store(0, Ordering::Relaxed);
256 self.last_ok_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
257 self.update_ewma(latency);
258 let prev = self.state();
259 if prev != BreakerState::Closed {
260 self.state
261 .store(BreakerState::Closed as u8, Ordering::Relaxed);
262 self.open_count.store(0, Ordering::Relaxed);
263 self.opened_unix_ms.store(0, Ordering::Relaxed);
264 return Some(BreakerTransition::Closed);
265 }
266 None
267 }
268
269 pub fn record_failure(&self, kind: ErrKind, cfg: &BreakerConfig) -> Option<BreakerTransition> {
276 self.total_calls.fetch_add(1, Ordering::Relaxed);
277 self.total_fail.fetch_add(1, Ordering::Relaxed);
278 let run = self.consec_fail.fetch_add(1, Ordering::Relaxed) + 1;
279 self.last_err_unix_ms
280 .store(now_unix_ms(), Ordering::Relaxed);
281 self.last_err_kind.store(kind as u8, Ordering::Relaxed);
282 let prev = self.state();
283 if prev == BreakerState::HalfOpen || run >= cfg.open_threshold {
287 self.open_breaker();
288 return Some(BreakerTransition::Opened);
289 }
290 None
291 }
292
293 fn open_breaker(&self) {
294 self.state
295 .store(BreakerState::Open as u8, Ordering::Relaxed);
296 self.open_count.fetch_add(1, Ordering::Relaxed);
297 self.opened_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
298 }
299
300 fn update_ewma(&self, latency: Duration) {
301 let sample = latency.as_micros() as u64;
302 let prev = self.ewma_latency_us.load(Ordering::Relaxed);
303 let next = if prev == 0 {
306 sample
307 } else if sample >= prev {
308 prev + (sample - prev) / 8
309 } else {
310 prev - (prev - sample) / 8
311 };
312 self.ewma_latency_us.store(next, Ordering::Relaxed);
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum BreakerTransition {
319 Opened,
320 Closed,
321}
322
323fn now_unix_ms() -> u64 {
327 SystemTime::now()
328 .duration_since(SystemTime::UNIX_EPOCH)
329 .map(|d| d.as_millis() as u64)
330 .unwrap_or(0)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn breaker_opens_after_threshold_and_skips() {
339 let cfg = BreakerConfig::default(); let h = HealthRecord::new();
341 assert!(h.available(&cfg));
342 assert_eq!(h.record_failure(ErrKind::Refused, &cfg), None);
343 assert_eq!(h.record_failure(ErrKind::Refused, &cfg), None);
344 assert_eq!(
346 h.record_failure(ErrKind::Refused, &cfg),
347 Some(BreakerTransition::Opened)
348 );
349 assert_eq!(h.state(), BreakerState::Open);
350 assert!(!h.is_up());
351 assert!(!h.available(&cfg));
353 }
354
355 #[test]
356 fn breaker_half_opens_after_cooldown_then_closes_on_success() {
357 let cfg = BreakerConfig {
359 open_threshold: 2,
360 cooldown: Duration::from_millis(1),
361 cooldown_max: Duration::from_millis(50),
362 };
363 let h = HealthRecord::new();
364 h.record_failure(ErrKind::Timeout, &cfg);
365 h.record_failure(ErrKind::Timeout, &cfg);
366 assert_eq!(h.state(), BreakerState::Open);
367 std::thread::sleep(Duration::from_millis(3));
368 assert!(h.available(&cfg));
370 assert_eq!(h.state(), BreakerState::HalfOpen);
371 assert_eq!(
373 h.record_success(Duration::from_millis(10)),
374 Some(BreakerTransition::Closed)
375 );
376 assert_eq!(h.state(), BreakerState::Closed);
377 assert_eq!(h.consec_fail(), 0);
378 }
379
380 #[test]
381 fn half_open_probe_failure_reopens_with_longer_cooldown() {
382 let cfg = BreakerConfig {
383 open_threshold: 1,
384 cooldown: Duration::from_millis(1),
385 cooldown_max: Duration::from_millis(1000),
386 };
387 let h = HealthRecord::new();
388 h.record_failure(ErrKind::Refused, &cfg);
390 let c1 = h.cooldown(&cfg);
391 std::thread::sleep(Duration::from_millis(3));
392 assert!(h.available(&cfg)); assert_eq!(
395 h.record_failure(ErrKind::Refused, &cfg),
396 Some(BreakerTransition::Opened)
397 );
398 let c2 = h.cooldown(&cfg);
399 assert!(c2 > c1, "cooldown backs off: {c1:?} -> {c2:?}");
400 }
401
402 #[test]
403 fn ewma_tracks_latency_and_error_rate() {
404 let h = HealthRecord::new();
405 h.record_success(Duration::from_millis(40));
406 assert_eq!(h.ewma_latency_ms(), 40);
407 h.record_success(Duration::from_millis(40));
409 assert!((39..=41).contains(&h.ewma_latency_ms()));
410 let cfg = BreakerConfig::default();
411 h.record_failure(ErrKind::Http5xx, &cfg);
412 assert!((h.error_rate() - 1.0 / 3.0).abs() < 1e-9);
414 }
415}