dwara_core/resilience/
breaker.rs

1//! Per-upstream circuit breaker (DW-015, feature analysis 4.11).
2//!
3//! # What it is
4//!
5//! The breaker gates an ENTIRE upstream (all endpoints) when that upstream
6//! is failing as a whole. It is a different layer from DW-012 per-ENDPOINT
7//! ejection: ejection removes individual endpoints from load-balancer
8//! rotation, the breaker stops sending ANY traffic to the upstream for a
9//! cooling-off period. When every endpoint is ejected the balancer
10//! fail-opens and picks anyway (DW-012); that pick still flows THROUGH the
11//! breaker — the breaker gates the upstream, ejection gates endpoints
12//! within it. The two never consume each other's state: an open breaker
13//! short-circuits before the balancer is consulted, so a breaker-open
14//! period ejects nothing (passive health sees no traffic, hence no
15//! failures, hence no ejections), and ejections never open the breaker.
16//!
17//! # States and transitions
18//!
19//! - **Closed** (healthy): requests flow. Failures are counted two ways —
20//!   a consecutive-failure streak and a rolling-window error ratio. The
21//!   breaker OPENS when either trips: the streak reaches
22//!   `consecutive_failures` (default 5), or the window holds at least
23//!   `error_volume` (default 20) observations AND
24//!   `failures / observations >= error_ratio` (default 0.5). The window is
25//!   60 seconds, the same rolling-event pattern as the retry budget.
26//! - **Open** (tripped): every request fails fast with `503` (body
27//!   "upstream circuit open") and a `Retry-After` header carrying the
28//!   whole seconds until half-open (rounded up, minimum 1). No attempts
29//!   are made — no endpoint pick, no retries. Requests already in flight
30//!   when the breaker opened complete normally (documented).
31//! - **HalfOpen** (probing): after `open_ms` (default 30000) the next
32//!   request is admitted as a trial probe (up to `half_open_probes`
33//!   concurrent probes, default 1). A successful probe CLOSES the breaker
34//!   (all counters reset, window cleared); a failed probe re-OPENS it for
35//!   another `open_ms`. While all probes are in flight, further requests
36//!   fail fast like Open (Retry-After: 1 second, the documented probing
37//!   hint — exact half-open time is unknowable while a probe runs).
38//!   A retried request consumes one half-open probe slot per attempt:
39//!   each attempt is a trial.
40//!
41//! # Observation points
42//!
43//! The breaker is evaluated at the SAME observation points as passive
44//! health: when an attempt's response HEADERS resolve — transport errors
45//! and statuses >= 500 are failures, everything else (1xx-4xx) is a
46//! success and counts toward the ratio denominator. Every retry attempt
47//! reports too (each attempt is a real exchange with the upstream). A
48//! mid-BODY abort after headers resolved does not open the breaker: the
49//! exchange was classified at header time (it is still reported to
50//! endpoint health per DW-014).
51//!
52//! Failure classification is intentionally identical to passive health so
53//! operators reason about one notion of "failure" across both layers.
54//!
55//! Timing is wall-clock (`SystemTime`): an NTP step can lengthen or
56//! shorten a wall-clock Open period.
57//!
58//! # Reload semantics
59//!
60//! Breaker STATE (current state, streak, window) is carried across config
61//! reloads keyed by upstream name — exactly like balancer state and the
62//! retry budget. Breaker PARAMETERS apply from the new config
63//! (`BreakerParams` is resolved per generation, `Breaker` holds state
64//! only, and every method takes the params explicitly — the same
65//! split as `RetryParams`/`RetryBudget`).
66//!
67//! # Events (DW-044)
68//!
69//! A breaker constructed with
70//! [`Breaker::with_clock_and_events`] emits one event
71//! per STATE TRANSITION (never per report): `breaker_opened` (with the
72//! rule that tripped, or "half_open_probe_failed" for a failed probe
73//! re-opening it), `breaker_half_open` (the first admitted probe after
74//! the cooling-off), and `breaker_closed` (a successful probe). Emission
75//! is the event bus's bounded non-blocking hand-off — a full queue drops
76//! and counts, never blocks the report path. A `None` emitter (every
77//! direct/test construction) is a documented no-op.
78
79use std::collections::VecDeque;
80// DW-025: loom-model-checked Mutex under the `loom` dev feature.
81#[cfg(feature = "loom")]
82use loom::sync::Mutex;
83#[cfg(not(feature = "loom"))]
84use std::sync::Mutex;
85
86use crate::config::BreakerConfig;
87
88/// Rolling window the error ratio accounts over (milliseconds).
89pub const BREAKER_WINDOW_MS: u64 = 60_000;
90/// Retry-After (milliseconds) advertised while half-open probes are in
91/// flight (exact half-open time is unknowable until a probe resolves).
92pub const HALF_OPEN_RETRY_AFTER_MS: u64 = 1_000;
93
94/// Resolved (validated) breaker parameters for one upstream. `None` on the
95/// upstream handle disables the breaker entirely — the no-config behavior
96/// is bit-identical to pre-DW-015.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct BreakerParams {
99    /// Consecutive failures (5xx + transport) that trip the breaker.
100    pub consecutive_failures: u32,
101    /// Error ratio in (0, 1] that trips the breaker once `error_volume`
102    /// observations are in the window.
103    pub error_ratio: f64,
104    /// Minimum in-window observations before the ratio is evaluated.
105    pub error_volume: u32,
106    /// Cooling-off period before a half-open probe is admitted.
107    pub open_ms: u64,
108    /// Concurrent trial requests admitted in half-open.
109    pub half_open_probes: u32,
110}
111
112impl BreakerParams {
113    /// Resolve from the config form; `None` keeps the disabled state the
114    /// caller represents separately (serde applies per-field defaults for a
115    /// present block).
116    pub fn from_config(cfg: &BreakerConfig) -> Self {
117        BreakerParams {
118            consecutive_failures: cfg.consecutive_failures,
119            error_ratio: cfg.error_ratio,
120            error_volume: cfg.error_volume,
121            open_ms: cfg.open_ms,
122            half_open_probes: cfg.half_open_probes,
123        }
124    }
125}
126
127/// Admission decision for one request about to be dispatched.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum BreakerDecision {
130    /// The request may proceed to the upstream.
131    Allow,
132    /// Fail fast; `retry_after_ms` is the advertised cooling hint.
133    Reject { retry_after_ms: u64 },
134}
135
136/// Breaker state snapshot (observability/tests).
137#[derive(Debug, Clone, PartialEq)]
138pub enum BreakerState {
139    /// Healthy; `consecutive` is the current failure streak.
140    Closed { consecutive: u32 },
141    /// Tripped until `until_ms` (Unix epoch milliseconds).
142    Open { until_ms: u64 },
143    /// Probing; `probes_left` trial requests may still be admitted.
144    HalfOpen { probes_left: u32 },
145}
146
147struct BreakerInner {
148    state: BreakerState,
149    window: VecDeque<(u64, bool)>,
150}
151
152/// Per-upstream circuit breaker state (see the module docs). Share via
153/// `Arc` from the upstream handle; the lock is taken once per attempt
154/// (report) and once per request admission (check).
155pub struct Breaker {
156    now_ms: fn() -> u64,
157    inner: Mutex<BreakerInner>,
158    /// Transition-event emitter (DW-044); `None` = no event wiring
159    /// (tests, direct construction). Bound to this breaker's upstream
160    /// label at construction; see the module docs.
161    events: Option<crate::events::UpstreamEmitter>,
162}
163
164fn system_now_ms() -> u64 {
165    std::time::SystemTime::now()
166        .duration_since(std::time::UNIX_EPOCH)
167        .map(|d| d.as_millis() as u64)
168        .unwrap_or(0)
169}
170
171impl Default for Breaker {
172    fn default() -> Self {
173        Breaker::new()
174    }
175}
176
177impl Breaker {
178    /// New closed breaker on the system clock.
179    pub fn new() -> Self {
180        Breaker::with_clock(system_now_ms)
181    }
182
183    /// New closed breaker with a caller-supplied millisecond clock (Unix
184    /// epoch). Intended for tests; production keeps the system clock.
185    pub fn with_clock(now_ms: fn() -> u64) -> Self {
186        Breaker::with_clock_and_events(now_ms, None)
187    }
188
189    /// `with_clock` with transition events (DW-044): the upstream
190    /// handle's wiring (the emitter is pre-bound to the upstream's
191    /// label; `None` keeps event emission off).
192    pub fn with_clock_and_events(
193        now_ms: fn() -> u64,
194        events: Option<crate::events::UpstreamEmitter>,
195    ) -> Self {
196        Breaker {
197            now_ms,
198            inner: Mutex::new(BreakerInner {
199                state: BreakerState::Closed { consecutive: 0 },
200                window: VecDeque::new(),
201            }),
202            events,
203        }
204    }
205
206    fn prune_locked(inner: &mut BreakerInner, now: u64) {
207        while inner
208            .window
209            .front()
210            .is_some_and(|&(t, _)| now.saturating_sub(t) >= BREAKER_WINDOW_MS)
211        {
212            inner.window.pop_front();
213        }
214    }
215
216    /// Emit one transition event (DW-044). Called with the inner lock
217    /// HELD, immediately after the transition is written (so the event
218    /// can never disagree with the state): emission is a `try_send`
219    /// plus an atomic — it never blocks and never fails the transition.
220    fn emit_transition_locked(&self, kind: crate::events::EventKind, detail: Option<&'static str>) {
221        if let Some(events) = &self.events {
222            events.breaker_transition(kind, detail);
223        }
224    }
225
226    /// Whether this request may be dispatched to the upstream. Transitions
227    /// Open -> HalfOpen when the cooling-off has elapsed (the caller's
228    /// request becomes a trial probe) and consumes one probe slot.
229    pub fn check(&self, params: &BreakerParams) -> BreakerDecision {
230        let now = (self.now_ms)();
231        let mut inner = self.inner.lock().expect("breaker poisoned");
232        Self::prune_locked(&mut inner, now);
233        match inner.state {
234            BreakerState::Closed { .. } => BreakerDecision::Allow,
235            BreakerState::Open { until_ms } => {
236                if now < until_ms {
237                    BreakerDecision::Reject {
238                        retry_after_ms: until_ms - now,
239                    }
240                } else {
241                    inner.state = BreakerState::HalfOpen {
242                        probes_left: params.half_open_probes.saturating_sub(1),
243                    };
244                    // DW-044: the cooling-off elapsed and this request
245                    // became the first half-open probe.
246                    self.emit_transition_locked(crate::events::EventKind::BreakerHalfOpen, None);
247                    BreakerDecision::Allow
248                }
249            }
250            BreakerState::HalfOpen { probes_left } => {
251                if probes_left == 0 {
252                    BreakerDecision::Reject {
253                        retry_after_ms: HALF_OPEN_RETRY_AFTER_MS,
254                    }
255                } else {
256                    inner.state = BreakerState::HalfOpen {
257                        probes_left: probes_left - 1,
258                    };
259                    BreakerDecision::Allow
260                }
261            }
262        }
263    }
264
265    /// Report one attempt outcome (observed at response-header
266    /// resolution; `failed` = transport error or status >= 500). Drives
267    /// every state transition; see the module docs.
268    pub fn report(&self, params: &BreakerParams, failed: bool) {
269        let now = (self.now_ms)();
270        let mut inner = self.inner.lock().expect("breaker poisoned");
271        Self::prune_locked(&mut inner, now);
272        inner.window.push_back((now, failed));
273        match inner.state {
274            BreakerState::Closed { consecutive } => {
275                if !failed {
276                    inner.state = BreakerState::Closed { consecutive: 0 };
277                    return;
278                }
279                let streak = consecutive + 1;
280                let total = inner.window.len() as u64;
281                let failures = inner.window.iter().filter(|(_, f)| *f).count() as u64;
282                let streak_tripped = streak >= params.consecutive_failures;
283                let ratio_tripped = total >= u64::from(params.error_volume)
284                    && failures as f64 / total as f64 >= params.error_ratio;
285                if streak_tripped || ratio_tripped {
286                    inner.state = BreakerState::Open {
287                        until_ms: now.saturating_add(params.open_ms),
288                    };
289                    // DW-044: name the rule that tripped (a stale detail
290                    // would send an operator chasing the wrong knob).
291                    let detail = if streak_tripped {
292                        "consecutive_failures"
293                    } else {
294                        "error_ratio"
295                    };
296                    self.emit_transition_locked(
297                        crate::events::EventKind::BreakerOpened,
298                        Some(detail),
299                    );
300                } else {
301                    inner.state = BreakerState::Closed {
302                        consecutive: streak,
303                    };
304                }
305            }
306            BreakerState::HalfOpen { .. } => {
307                if failed {
308                    // A failed probe re-opens for another full cool-off.
309                    inner.state = BreakerState::Open {
310                        until_ms: now.saturating_add(params.open_ms),
311                    };
312                    self.emit_transition_locked(
313                        crate::events::EventKind::BreakerOpened,
314                        Some("half_open_probe_failed"),
315                    );
316                } else {
317                    // A successful probe closes and RESETS counters (the
318                    // window too): stale pre-trip failures must not
319                    // instantly re-trip the ratio.
320                    inner.state = BreakerState::Closed { consecutive: 0 };
321                    inner.window.clear();
322                    self.emit_transition_locked(
323                        crate::events::EventKind::BreakerClosed,
324                        Some("half_open_probe_succeeded"),
325                    );
326                }
327            }
328            BreakerState::Open { .. } => {
329                // In-flight requests (admitted before the trip) still
330                // report; their outcomes land in the window but cannot
331                // change an already-open state.
332            }
333        }
334    }
335
336    /// Current state (observability/tests).
337    pub fn state(&self) -> BreakerState {
338        let now = (self.now_ms)();
339        let mut inner = self.inner.lock().expect("breaker poisoned");
340        Self::prune_locked(&mut inner, now);
341        inner.state.clone()
342    }
343
344    /// In-window observation totals (observability/tests).
345    pub fn totals(&self) -> usize {
346        let now = (self.now_ms)();
347        let mut inner = self.inner.lock().expect("breaker poisoned");
348        Self::prune_locked(&mut inner, now);
349        inner.window.len()
350    }
351
352    /// In-window failures (observability/tests).
353    pub fn failures(&self) -> usize {
354        let now = (self.now_ms)();
355        let mut inner = self.inner.lock().expect("breaker poisoned");
356        Self::prune_locked(&mut inner, now);
357        inner.window.iter().filter(|(_, f)| *f).count()
358    }
359}
360
361impl std::fmt::Debug for Breaker {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        f.debug_struct("Breaker")
364            .field("state", &self.state())
365            .field("totals", &self.totals())
366            .field("failures", &self.failures())
367            .finish()
368    }
369}