1use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
11use std::time::{Duration, SystemTime};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BreakerState {
17 Closed = 0,
19 Open = 1,
21 HalfOpen = 2,
23}
24
25impl BreakerState {
26 fn from_u8(v: u8) -> BreakerState {
27 match v {
28 1 => BreakerState::Open,
29 2 => BreakerState::HalfOpen,
30 _ => BreakerState::Closed,
31 }
32 }
33 pub fn as_str(self) -> &'static str {
35 match self {
36 BreakerState::Closed => "closed",
37 BreakerState::Open => "open",
38 BreakerState::HalfOpen => "half-open",
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ErrKind {
47 None = 0,
48 Refused = 1,
49 Reset = 2,
50 Timeout = 3,
51 Http5xx = 4,
52 Http429 = 5,
53 Probe = 6,
54}
55
56impl ErrKind {
57 fn from_u8(v: u8) -> ErrKind {
58 match v {
59 1 => ErrKind::Refused,
60 2 => ErrKind::Reset,
61 3 => ErrKind::Timeout,
62 4 => ErrKind::Http5xx,
63 5 => ErrKind::Http429,
64 6 => ErrKind::Probe,
65 _ => ErrKind::None,
66 }
67 }
68 pub fn as_str(self) -> &'static str {
69 match self {
70 ErrKind::None => "none",
71 ErrKind::Refused => "refused",
72 ErrKind::Reset => "reset",
73 ErrKind::Timeout => "timeout",
74 ErrKind::Http5xx => "5xx",
75 ErrKind::Http429 => "429",
76 ErrKind::Probe => "probe",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy)]
84pub struct BreakerConfig {
85 pub open_threshold: u32,
87 pub cooldown: Duration,
89 pub cooldown_max: Duration,
91}
92
93impl Default for BreakerConfig {
94 fn default() -> BreakerConfig {
95 BreakerConfig {
96 open_threshold: 3,
97 cooldown: Duration::from_secs(5),
98 cooldown_max: Duration::from_secs(60),
99 }
100 }
101}
102
103#[derive(Debug)]
106pub struct HealthRecord {
107 state: AtomicU8, consec_fail: AtomicU32, total_calls: AtomicU64,
110 total_fail: AtomicU64, ewma_latency_us: AtomicU64,
112 last_ok_unix_ms: AtomicU64,
113 last_err_unix_ms: AtomicU64,
114 last_err_kind: AtomicU8, opened_unix_ms: AtomicU64,
116 open_count: AtomicU32,
119}
120
121impl Default for HealthRecord {
122 fn default() -> HealthRecord {
123 HealthRecord::new()
124 }
125}
126
127impl HealthRecord {
128 pub const fn new() -> HealthRecord {
129 HealthRecord {
130 state: AtomicU8::new(BreakerState::Closed as u8),
131 consec_fail: AtomicU32::new(0),
132 total_calls: AtomicU64::new(0),
133 total_fail: AtomicU64::new(0),
134 ewma_latency_us: AtomicU64::new(0),
135 last_ok_unix_ms: AtomicU64::new(0),
136 last_err_unix_ms: AtomicU64::new(0),
137 last_err_kind: AtomicU8::new(ErrKind::None as u8),
138 opened_unix_ms: AtomicU64::new(0),
139 open_count: AtomicU32::new(0),
140 }
141 }
142
143 pub fn state(&self) -> BreakerState {
144 BreakerState::from_u8(self.state.load(Ordering::Relaxed))
145 }
146
147 pub fn consec_fail(&self) -> u32 {
148 self.consec_fail.load(Ordering::Relaxed)
149 }
150
151 pub fn total_calls(&self) -> u64 {
152 self.total_calls.load(Ordering::Relaxed)
153 }
154
155 pub fn total_fail(&self) -> u64 {
156 self.total_fail.load(Ordering::Relaxed)
157 }
158
159 pub fn error_rate(&self) -> f64 {
162 let calls = self.total_calls();
163 if calls == 0 {
164 0.0
165 } else {
166 self.total_fail() as f64 / calls as f64
167 }
168 }
169
170 pub fn ewma_latency_ms(&self) -> u64 {
171 self.ewma_latency_us.load(Ordering::Relaxed) / 1000
172 }
173
174 pub fn last_err_kind(&self) -> ErrKind {
175 ErrKind::from_u8(self.last_err_kind.load(Ordering::Relaxed))
176 }
177
178 pub fn last_ok_ms_ago(&self) -> Option<u64> {
179 let t = self.last_ok_unix_ms.load(Ordering::Relaxed);
180 if t == 0 {
181 None
182 } else {
183 Some(now_unix_ms().saturating_sub(t))
184 }
185 }
186
187 pub fn opened_ms_ago(&self) -> Option<u64> {
188 let t = self.opened_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 cooldown(&self, cfg: &BreakerConfig) -> Duration {
198 let n = self.open_count.load(Ordering::Relaxed).saturating_sub(1);
199 let shift = n.min(20); let scaled = cfg.cooldown.saturating_mul(1u32 << shift);
201 scaled.min(cfg.cooldown_max)
202 }
203
204 pub fn available(&self, cfg: &BreakerConfig) -> bool {
208 match self.state() {
209 BreakerState::Closed | BreakerState::HalfOpen => true,
210 BreakerState::Open => {
211 if self.opened_ms_ago().unwrap_or(0) >= self.cooldown(cfg).as_millis() as u64 {
214 self.state
215 .store(BreakerState::HalfOpen as u8, Ordering::Relaxed);
216 true
217 } else {
218 false
219 }
220 }
221 }
222 }
223
224 pub fn is_up(&self) -> bool {
227 self.state() != BreakerState::Open
228 }
229
230 pub fn record_success(&self, latency: Duration) -> Option<BreakerTransition> {
234 self.total_calls.fetch_add(1, Ordering::Relaxed);
235 self.consec_fail.store(0, Ordering::Relaxed);
236 self.last_ok_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
237 self.update_ewma(latency);
238 let prev = self.state();
239 if prev != BreakerState::Closed {
240 self.state
241 .store(BreakerState::Closed as u8, Ordering::Relaxed);
242 self.open_count.store(0, Ordering::Relaxed);
243 self.opened_unix_ms.store(0, Ordering::Relaxed);
244 return Some(BreakerTransition::Closed);
245 }
246 None
247 }
248
249 pub fn record_failure(&self, kind: ErrKind, cfg: &BreakerConfig) -> Option<BreakerTransition> {
253 self.total_calls.fetch_add(1, Ordering::Relaxed);
254 self.total_fail.fetch_add(1, Ordering::Relaxed);
255 let run = self.consec_fail.fetch_add(1, Ordering::Relaxed) + 1;
256 self.last_err_unix_ms
257 .store(now_unix_ms(), Ordering::Relaxed);
258 self.last_err_kind.store(kind as u8, Ordering::Relaxed);
259 let prev = self.state();
260 if prev == BreakerState::HalfOpen || run >= cfg.open_threshold {
263 self.open_breaker();
264 return Some(BreakerTransition::Opened);
265 }
266 None
267 }
268
269 fn open_breaker(&self) {
270 self.state
271 .store(BreakerState::Open as u8, Ordering::Relaxed);
272 self.open_count.fetch_add(1, Ordering::Relaxed);
273 self.opened_unix_ms.store(now_unix_ms(), Ordering::Relaxed);
274 }
275
276 fn update_ewma(&self, latency: Duration) {
277 let sample = latency.as_micros() as u64;
278 let prev = self.ewma_latency_us.load(Ordering::Relaxed);
279 let next = if prev == 0 {
282 sample
283 } else if sample >= prev {
284 prev + (sample - prev) / 8
285 } else {
286 prev - (prev - sample) / 8
287 };
288 self.ewma_latency_us.store(next, Ordering::Relaxed);
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum BreakerTransition {
295 Opened,
296 Closed,
297}
298
299fn now_unix_ms() -> u64 {
302 SystemTime::now()
303 .duration_since(SystemTime::UNIX_EPOCH)
304 .map(|d| d.as_millis() as u64)
305 .unwrap_or(0)
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn breaker_opens_after_threshold_and_skips() {
314 let cfg = BreakerConfig::default(); let h = HealthRecord::new();
316 assert!(h.available(&cfg));
317 assert_eq!(h.record_failure(ErrKind::Refused, &cfg), None);
318 assert_eq!(h.record_failure(ErrKind::Refused, &cfg), None);
319 assert_eq!(
321 h.record_failure(ErrKind::Refused, &cfg),
322 Some(BreakerTransition::Opened)
323 );
324 assert_eq!(h.state(), BreakerState::Open);
325 assert!(!h.is_up());
326 assert!(!h.available(&cfg));
328 }
329
330 #[test]
331 fn breaker_half_opens_after_cooldown_then_closes_on_success() {
332 let cfg = BreakerConfig {
334 open_threshold: 2,
335 cooldown: Duration::from_millis(1),
336 cooldown_max: Duration::from_millis(50),
337 };
338 let h = HealthRecord::new();
339 h.record_failure(ErrKind::Timeout, &cfg);
340 h.record_failure(ErrKind::Timeout, &cfg);
341 assert_eq!(h.state(), BreakerState::Open);
342 std::thread::sleep(Duration::from_millis(3));
343 assert!(h.available(&cfg));
345 assert_eq!(h.state(), BreakerState::HalfOpen);
346 assert_eq!(
348 h.record_success(Duration::from_millis(10)),
349 Some(BreakerTransition::Closed)
350 );
351 assert_eq!(h.state(), BreakerState::Closed);
352 assert_eq!(h.consec_fail(), 0);
353 }
354
355 #[test]
356 fn half_open_probe_failure_reopens_with_longer_cooldown() {
357 let cfg = BreakerConfig {
358 open_threshold: 1,
359 cooldown: Duration::from_millis(1),
360 cooldown_max: Duration::from_millis(1000),
361 };
362 let h = HealthRecord::new();
363 h.record_failure(ErrKind::Refused, &cfg);
365 let c1 = h.cooldown(&cfg);
366 std::thread::sleep(Duration::from_millis(3));
367 assert!(h.available(&cfg)); assert_eq!(
370 h.record_failure(ErrKind::Refused, &cfg),
371 Some(BreakerTransition::Opened)
372 );
373 let c2 = h.cooldown(&cfg);
374 assert!(c2 > c1, "cooldown backs off: {c1:?} -> {c2:?}");
375 }
376
377 #[test]
378 fn ewma_tracks_latency_and_error_rate() {
379 let h = HealthRecord::new();
380 h.record_success(Duration::from_millis(40));
381 assert_eq!(h.ewma_latency_ms(), 40);
382 h.record_success(Duration::from_millis(40));
384 assert!((39..=41).contains(&h.ewma_latency_ms()));
385 let cfg = BreakerConfig::default();
386 h.record_failure(ErrKind::Http5xx, &cfg);
387 assert!((h.error_rate() - 1.0 / 3.0).abs() < 1e-9);
389 }
390}