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}