Skip to main content

agentd/intel/
health.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Per-endpoint health record and circuit breaker.
3//!
4//! Always compiled and dependency-free. The failover policy consults these
5//! records to skip a dead endpoint and to snap back to the primary. All state is
6//! plain integers and atomics — no histogram library, no SDK, no background
7//! timer thread. The breaker is decided **synchronously** against the wall clock
8//! at the moment an endpoint is consulted, so there is no async runtime and no
9//! prober thread: an idle agent runs no code here at all, and a breaker can
10//! never be reopened by a timer racing a live request.
11
12use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering};
13use std::time::{Duration, SystemTime};
14
15/// Three-state circuit breaker. Stored as a `u8` in the health record so the
16/// whole record stays lock-free and shareable behind `&self`.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BreakerState {
19    /// Normal — in rotation.
20    Closed = 0,
21    /// Removed from rotation for a cooldown after N consecutive failures.
22    Open = 1,
23    /// Eligible for exactly one probe; success re-closes, failure re-opens.
24    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    /// The wire spelling published in the `agentd://intelligence` resource body.
36    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/// The last-observed failure class for an endpoint. A small bounded enum rather
46/// than a message string, so the resource body and the emitted events can name
47/// the failure without allocating and without ever echoing provider text into an
48/// observable surface.
49#[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/// Breaker tuning. Every list is constructed with [`BreakerConfig::default`],
86/// so these defaults are the operative values and the tuning is not currently
87/// exposed to operators.
88#[derive(Debug, Clone, Copy)]
89pub struct BreakerConfig {
90    /// Consecutive failover-class failures that open the breaker.
91    pub open_threshold: u32,
92    /// Initial cooldown after the breaker opens.
93    pub cooldown: Duration,
94    /// Cooldown cap (cooldown doubles each consecutive open up to this).
95    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/// Per-endpoint health and breaker state. Every field is an atomic so the
109/// record can be updated through a shared `&self` on the dial path without a
110/// lock, which is what lets `EndpointList::iter` hand out `&Endpoint` while a
111/// call in flight records its outcome.
112#[derive(Debug)]
113pub struct HealthRecord {
114    state: AtomicU8,        // BreakerState
115    consec_fail: AtomicU32, // resets to 0 on success
116    total_calls: AtomicU64,
117    total_fail: AtomicU64, // failover-class failures
118    ewma_latency_us: AtomicU64,
119    last_ok_unix_ms: AtomicU64,
120    last_err_unix_ms: AtomicU64,
121    last_err_kind: AtomicU8, // ErrKind
122    opened_unix_ms: AtomicU64,
123    /// How many times the breaker has consecutively opened (the cooldown
124    /// backoff multiplier; reset on a re-close).
125    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    /// Process-lifetime error rate, `total_fail / total_calls`. It is a lifetime
167    /// figure and never decays, so a long-lived agent's value lags a recent
168    /// recovery; a windowed rate is the collector's job, derived from the
169    /// scraped counters rather than kept here.
170    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    /// The current cooldown for this endpoint: the initial cooldown doubled once
206    /// per consecutive open, capped at `cooldown_max`. A repeatedly failing
207    /// endpoint is therefore probed less and less often, while one that closes
208    /// again resets to the initial cooldown.
209    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); // avoid overflow; 2^20 already past the cap
212        let scaled = cfg.cooldown.saturating_mul(1u32 << shift);
213        scaled.min(cfg.cooldown_max)
214    }
215
216    /// True if this endpoint is currently usable, i.e. not OPEN and still
217    /// cooling. Called by `attempt_order()`. **This is not a pure read**: an
218    /// endpoint whose cooldown has elapsed is promoted to HALF-OPEN here, so the
219    /// next call probes it. Use [`is_up`](Self::is_up) where a side-effect-free
220    /// answer is required.
221    pub fn available(&self, cfg: &BreakerConfig) -> bool {
222        match self.state() {
223            BreakerState::Closed | BreakerState::HalfOpen => true,
224            BreakerState::Open => {
225                // Promote to HALF-OPEN once the cooldown has elapsed. There is
226                // no timer thread, so the consult itself is the promotion —
227                // which also means an endpoint nobody consults never recovers,
228                // and never needs to.
229                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    /// True if the breaker is not OPEN, i.e. the endpoint is in rotation. This
241    /// is the meaning of the `agentd_intel_endpoint_up` gauge. Unlike
242    /// [`available`](Self::available) it is a pure read and never promotes a
243    /// cooled-down breaker, so observing an endpoint cannot change its state.
244    pub fn is_up(&self) -> bool {
245        self.state() != BreakerState::Open
246    }
247
248    /// Record a successful round-trip: reset the consecutive-failure run,
249    /// re-close the breaker, and fold the latency into the EWMA (alpha = 1/8).
250    /// Returns the breaker transition if one happened, which the caller emits as
251    /// an event and reflects in the served resource body; `None` means the
252    /// breaker was already CLOSED and nothing is worth reporting.
253    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    /// Record a failover-class failure: bump the consecutive-failure run, stamp
270    /// the error kind, and open the breaker if the run crossed the threshold or
271    /// a HALF-OPEN probe failed. Only failover-class failures belong here — an
272    /// auth or bad-request error is identical on every endpoint, so counting it
273    /// would open breakers across a perfectly healthy list. Returns the breaker
274    /// transition if one happened, for the caller to emit.
275    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        // A HALF-OPEN probe failure, or crossing the threshold from CLOSED,
284        // opens the breaker and bumps the cooldown backoff. A single failed
285        // probe is enough: the endpoint has already proved itself unhealthy.
286        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        // EWMA alpha = 1/8: new = prev + (sample - prev)/8. Seed with the first
304        // sample so a cold endpoint reports its real latency immediately.
305        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/// A breaker state transition worth surfacing to an operator as an event.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum BreakerTransition {
319    Opened,
320    Closed,
321}
322
323/// Wall-clock now in unix milliseconds. Saturating, and `0` only on a pre-epoch
324/// clock; every reader treats `0` as "unknown" rather than as a real timestamp,
325/// so a broken clock degrades to no-information instead of a bogus age.
326fn 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(); // threshold 3
340        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        // third consecutive failure opens
345        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        // a freshly-opened breaker is skipped (cooldown not elapsed)
352        assert!(!h.available(&cfg));
353    }
354
355    #[test]
356    fn breaker_half_opens_after_cooldown_then_closes_on_success() {
357        // Tiny cooldown so the test doesn't sleep meaningfully.
358        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        // consult promotes OPEN → HALF-OPEN once the cooldown elapsed
369        assert!(h.available(&cfg));
370        assert_eq!(h.state(), BreakerState::HalfOpen);
371        // a successful probe closes it and resets the run
372        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        // open #1
389        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)); // → HALF-OPEN
393        // probe fails → re-open, cooldown doubles
394        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        // a few successes keep it near 40ms
408        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        // 1 failure / 3 calls
413        assert!((h.error_rate() - 1.0 / 3.0).abs() < 1e-9);
414    }
415}