Skip to main content

agentd/intel/
health.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Per-endpoint health record + circuit breaker. RFC 0018 §4.1/§4.2.
3//!
4//! Core (always compiled, dependency-free): the failover policy (§3.3) consults
5//! these to skip a dead endpoint and snap back to the primary. All state is
6//! integers/atomics — no histogram library, no SDK, no background timer thread.
7//! The breaker is decided **synchronously** against a wall clock when an endpoint
8//! is consulted (RFC 0018 §4.2 / §7 — no async runtime, no prober thread).
9
10use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
11use std::time::{Duration, SystemTime};
12
13/// Three-state circuit breaker (RFC 0018 §4.2). Stored as a `u8` in the health
14/// record so the whole record is lock-free.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BreakerState {
17    /// Normal — in rotation.
18    Closed = 0,
19    /// Removed from rotation for a cooldown after N consecutive failures.
20    Open = 1,
21    /// Eligible for exactly one probe; success re-closes, failure re-opens.
22    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    /// The §4.4 resource-body string.
34    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/// The last-observed failure class for an endpoint (a small bounded enum so the
44/// §4.4 resource body and §8 events can name it without a string allocation).
45#[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/// Breaker tuning (RFC 0018 §4.2). All overridable via env/flag in a later
82/// integration; the defaults are the public contract.
83#[derive(Debug, Clone, Copy)]
84pub struct BreakerConfig {
85    /// Consecutive failover-class failures that open the breaker.
86    pub open_threshold: u32,
87    /// Initial cooldown after the breaker opens.
88    pub cooldown: Duration,
89    /// Cooldown cap (cooldown doubles each consecutive open up to this).
90    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/// Per-endpoint health + breaker state (RFC 0018 §4.1). Every field is an atomic
104/// so the record is shared (`&self`) across the call path without a lock.
105#[derive(Debug)]
106pub struct HealthRecord {
107    state: AtomicU8,        // BreakerState
108    consec_fail: AtomicU32, // resets to 0 on success
109    total_calls: AtomicU64,
110    total_fail: AtomicU64, // failover-class failures
111    ewma_latency_us: AtomicU64,
112    last_ok_unix_ms: AtomicU64,
113    last_err_unix_ms: AtomicU64,
114    last_err_kind: AtomicU8, // ErrKind
115    opened_unix_ms: AtomicU64,
116    /// How many times the breaker has consecutively opened (the cooldown
117    /// backoff multiplier; reset on a re-close).
118    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    /// Process-lifetime error rate (`total_fail / total_calls`); a windowed rate
160    /// is computed by the collector from the scraped counters (RFC 0018 §4.1).
161    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    /// The current cooldown for this endpoint (initial × 2^open_count, capped).
197    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); // avoid overflow; 2^20 already past the cap
200        let scaled = cfg.cooldown.saturating_mul(1u32 << shift);
201        scaled.min(cfg.cooldown_max)
202    }
203
204    /// True if this endpoint is currently usable (not OPEN-and-cooling). Called
205    /// by `attempt_order()` (§3.3): an endpoint whose cooldown elapsed is
206    /// promoted to HALF-OPEN here so the next call probes it.
207    pub fn available(&self, cfg: &BreakerConfig) -> bool {
208        match self.state() {
209            BreakerState::Closed | BreakerState::HalfOpen => true,
210            BreakerState::Open => {
211                // Promote to HALF-OPEN if the cooldown elapsed (§4.2: "the next
212                // consult promotes it"). The promotion is the consult.
213                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    /// `1` if the breaker is not OPEN (in rotation) — the `agentd_intel_endpoint_up`
225    /// gauge meaning (§4.3). Read-only (no promotion side effect).
226    pub fn is_up(&self) -> bool {
227        self.state() != BreakerState::Open
228    }
229
230    /// Record a successful round-trip: reset the failure run, re-close the
231    /// breaker, and fold the latency into the EWMA (alpha = 1/8). Returns the
232    /// breaker transition that happened (for the §8 event / §4.4 emission).
233    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    /// Record a failover-class failure: bump the run, stamp the error kind, and
250    /// open the breaker if we crossed the threshold (or a HALF-OPEN probe
251    /// failed). Returns the breaker transition (for the §8 event / §4.4 emission).
252    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        // A HALF-OPEN probe failure, or crossing the threshold from CLOSED,
261        // opens the breaker (and bumps the cooldown backoff).
262        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        // EWMA alpha = 1/8: new = prev + (sample - prev)/8. Seed with the first
280        // sample so a cold endpoint reports its real latency immediately.
281        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/// A breaker state transition worth surfacing (RFC 0018 §4.4/§8).
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum BreakerTransition {
295    Opened,
296    Closed,
297}
298
299/// Wall-clock now in unix milliseconds (saturating; `0` only on a pre-epoch
300/// clock, which we treat as "unknown").
301fn 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(); // threshold 3
315        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        // third consecutive failure opens
320        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        // a freshly-opened breaker is skipped (cooldown not elapsed)
327        assert!(!h.available(&cfg));
328    }
329
330    #[test]
331    fn breaker_half_opens_after_cooldown_then_closes_on_success() {
332        // Tiny cooldown so the test doesn't sleep meaningfully.
333        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        // consult promotes OPEN → HALF-OPEN once the cooldown elapsed
344        assert!(h.available(&cfg));
345        assert_eq!(h.state(), BreakerState::HalfOpen);
346        // a successful probe closes it and resets the run
347        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        // open #1
364        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)); // → HALF-OPEN
368        // probe fails → re-open, cooldown doubles
369        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        // a few successes keep it near 40ms
383        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        // 1 failure / 3 calls
388        assert!((h.error_rate() - 1.0 / 3.0).abs() < 1e-9);
389    }
390}