Skip to main content

rust_web_server/circuit_breaker/
mod.rs

1//! Circuit breaker state machine and retry middleware.
2//!
3//! # Circuit breaker
4//!
5//! [`CircuitBreaker`] tracks per-backend failure counts and transitions through
6//! three states:
7//!
8//! * **Closed** — the backend is healthy; failures are counted.  When the count
9//!   reaches `failure_threshold` the breaker moves to **Open**.
10//! * **Open** — the backend is considered unhealthy; all requests are rejected
11//!   immediately (no TCP connection is attempted).  After `recovery` seconds the
12//!   breaker moves to **HalfOpen**.
13//! * **HalfOpen** — one probe request is let through.  On success the breaker
14//!   closes; on failure it re-opens and the recovery timer resets.
15//!
16//! # Retry middleware
17//!
18//! [`RetryLayer`] wraps any [`Application`] and re-dispatches the request when
19//! the inner app returns one of the configured status codes (default: 502, 503,
20//! 504) up to `max_retries` additional times.
21//!
22//! # Persistence
23//!
24//! [`CircuitBreaker`] keeps state in a plain in-process `HashMap` — a restart
25//! (or a deploy) resets every backend back to `Closed`, so a backend that
26//! tripped the breaker moments before a restart looks healthy again
27//! immediately, and may cascade failures again before anything notices.
28//! [`RedisCircuitBreaker`] has the same state machine and the same
29//! `is_available`/`record_success`/`record_failure`/`reset`/`state` shape, but
30//! persists each backend's state in Redis (via the same hand-rolled RESP
31//! client [`crate::rate_limit::RedisRateLimiter`] and
32//! [`crate::session::RedisSessionStore`] use) — surviving a restart, and
33//! shared across every `rws` instance pointed at that Redis server.
34//!
35//! # Example
36//!
37//! ```rust,no_run
38//! use rust_web_server::app::App;
39//! use rust_web_server::core::New;
40//! use rust_web_server::circuit_breaker::RetryLayer;
41//! use rust_web_server::middleware::WithMiddleware;
42//!
43//! let app = WithMiddleware::new(App::new())
44//!     .wrap(RetryLayer::new().max_retries(2));
45//! ```
46
47#[cfg(test)]
48mod tests;
49
50use std::collections::HashMap;
51use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
52use std::sync::{Arc, Mutex, OnceLock};
53use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
54
55use crate::application::Application;
56use crate::middleware::Middleware;
57use crate::redis_protocol::{RespConn, RespReply};
58use crate::request::Request;
59use crate::response::Response;
60use crate::server::ConnectionInfo;
61
62// ── BreakerState ─────────────────────────────────────────────────────────────
63
64/// Current state of a single backend's circuit breaker.
65#[derive(Debug, Clone, PartialEq)]
66pub enum BreakerState {
67    /// Healthy — requests are forwarded and failures are counted.
68    Closed,
69    /// Unhealthy — requests are rejected until the recovery window expires.
70    Open,
71    /// Probing — one request is let through to test backend health.
72    HalfOpen,
73}
74
75// ── BackendEntry ──────────────────────────────────────────────────────────────
76
77struct BackendEntry {
78    state: BreakerState,
79    failures: u32,
80    opened_at: Option<Instant>,
81}
82
83impl BackendEntry {
84    fn new() -> Self {
85        Self { state: BreakerState::Closed, failures: 0, opened_at: None }
86    }
87}
88
89// ── CircuitBreaker ────────────────────────────────────────────────────────────
90
91/// Per-backend circuit breaker.
92///
93/// # Concurrency
94///
95/// `CircuitBreaker` is not `Sync` on its own — wrap it in a [`Mutex`] for
96/// shared use across threads (see [`global()`]).
97pub struct CircuitBreaker {
98    backends: HashMap<String, BackendEntry>,
99    failure_threshold: u32,
100    recovery: Duration,
101}
102
103impl CircuitBreaker {
104    /// Create a new circuit breaker.
105    ///
106    /// * `failure_threshold` — how many consecutive failures open the circuit.
107    /// * `recovery_secs` — how long the circuit stays Open before testing again.
108    pub fn new(failure_threshold: u32, recovery_secs: u64) -> Self {
109        Self {
110            backends: HashMap::new(),
111            failure_threshold,
112            recovery: Duration::from_secs(recovery_secs),
113        }
114    }
115
116    /// Returns `true` if a request should be forwarded to `backend`.
117    ///
118    /// Transitions `Open → HalfOpen` when the recovery window has elapsed.
119    pub fn is_available(&mut self, backend: &str) -> bool {
120        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
121        match entry.state {
122            BreakerState::Closed => true,
123            BreakerState::HalfOpen => true,
124            BreakerState::Open => {
125                if let Some(opened_at) = entry.opened_at {
126                    if opened_at.elapsed() >= self.recovery {
127                        entry.state = BreakerState::HalfOpen;
128                        entry.opened_at = None;
129                        return true;
130                    }
131                }
132                false
133            }
134        }
135    }
136
137    /// Record a successful response for `backend`.
138    ///
139    /// Transitions `HalfOpen → Closed` and resets the failure counter.
140    pub fn record_success(&mut self, backend: &str) {
141        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
142        entry.state = BreakerState::Closed;
143        entry.failures = 0;
144        entry.opened_at = None;
145    }
146
147    /// Record a failed response for `backend`.
148    ///
149    /// In `Closed` state, increments the counter and opens the circuit when
150    /// `failure_threshold` is reached.  In `HalfOpen` state, immediately
151    /// re-opens the circuit and resets the recovery timer.
152    pub fn record_failure(&mut self, backend: &str) {
153        let threshold = self.failure_threshold;
154        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
155        match entry.state {
156            BreakerState::Closed => {
157                entry.failures += 1;
158                if entry.failures >= threshold {
159                    entry.state = BreakerState::Open;
160                    entry.opened_at = Some(Instant::now());
161                }
162            }
163            BreakerState::HalfOpen => {
164                entry.state = BreakerState::Open;
165                entry.opened_at = Some(Instant::now());
166            }
167            BreakerState::Open => {
168                // Already open; refresh the timer.
169                entry.opened_at = Some(Instant::now());
170            }
171        }
172    }
173
174    /// Reset `backend` to `Closed` with zero failures.
175    pub fn reset(&mut self, backend: &str) {
176        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
177        entry.state = BreakerState::Closed;
178        entry.failures = 0;
179        entry.opened_at = None;
180    }
181
182    /// Return the current state for `backend` (defaults to `Closed` if unseen).
183    pub fn state(&self, backend: &str) -> BreakerState {
184        self.backends
185            .get(backend)
186            .map(|e| e.state.clone())
187            .unwrap_or(BreakerState::Closed)
188    }
189}
190
191// ── global() ─────────────────────────────────────────────────────────────────
192
193static GLOBAL_BREAKER: OnceLock<Mutex<CircuitBreaker>> = OnceLock::new();
194
195/// Return the process-wide default circuit breaker (threshold=5, recovery=30 s).
196///
197/// Acquire the mutex before calling any `CircuitBreaker` method:
198///
199/// ```rust
200/// use rust_web_server::circuit_breaker;
201///
202/// let available = circuit_breaker::global().lock().unwrap().is_available("backend-a:8080");
203/// ```
204pub fn global() -> &'static Mutex<CircuitBreaker> {
205    GLOBAL_BREAKER.get_or_init(|| Mutex::new(CircuitBreaker::new(5, 30)))
206}
207
208// ── RedisCircuitBreaker ─────────────────────────────────────────────────────────
209
210/// Per-backend circuit breaker, persisted in Redis.
211///
212/// Same Closed → Open → HalfOpen state machine as [`CircuitBreaker`], and the
213/// same method names, but every operation is a synchronous Redis round trip
214/// (via the shared [`crate::redis_protocol`] RESP client) instead of an
215/// in-memory `HashMap` update — so state survives a process restart, and is
216/// shared across every `rws` instance pointed at the same Redis server.
217///
218/// # Why Redis, not the model layer
219///
220/// The model layer (`DbPool`, `sqlx`) is `async fn`-only, while
221/// `CircuitBreaker`'s methods and `Middleware::handle` (what [`RetryLayer`]
222/// implements) are both synchronous — the identical async/sync mismatch that
223/// left `SqliteRateLimiter` unbuilt after [`crate::rate_limit::RedisRateLimiter`]
224/// shipped. Redis, reached over a plain blocking `TcpStream`, stays fully
225/// synchronous and drops into the same call sites `CircuitBreaker` already
226/// has, with no new Cargo dependency.
227///
228/// # Consistency
229///
230/// Each operation is a read-then-write (`GET` then `SET`) against one Redis
231/// key per backend — not a single atomic command. Two `rws` instances racing
232/// to record a failure for the same backend at the same instant can lose one
233/// of the two increments. This is a deliberate simplification: unlike a rate
234/// limit (a hard resource/security boundary, where `RedisRateLimiter` uses
235/// genuinely atomic `INCR` for exactly this reason), a circuit breaker is a
236/// self-healing heuristic where opening one failure late — or one request
237/// later than a perfectly-synchronized count would — has no real consequence.
238///
239/// # Example
240///
241/// ```rust,no_run
242/// use rust_web_server::circuit_breaker::RedisCircuitBreaker;
243///
244/// let breaker = RedisCircuitBreaker::new("127.0.0.1:6379", None, 5, 30);
245///
246/// match breaker.is_available("backend-a:8080") {
247///     Ok(true) => { /* forward the request */ }
248///     Ok(false) => { /* short-circuit — return 503 without contacting the backend */ }
249///     Err(e) => { /* Redis unreachable — decide fail-open vs fail-closed yourself */ }
250/// }
251/// ```
252pub struct RedisCircuitBreaker {
253    conn: Arc<RespConn>,
254    failure_threshold: AtomicU32,
255    recovery_secs: AtomicU64,
256}
257
258impl Clone for RedisCircuitBreaker {
259    fn clone(&self) -> Self {
260        RedisCircuitBreaker {
261            conn: Arc::clone(&self.conn),
262            failure_threshold: AtomicU32::new(self.failure_threshold.load(Ordering::Relaxed)),
263            recovery_secs: AtomicU64::new(self.recovery_secs.load(Ordering::Relaxed)),
264        }
265    }
266}
267
268impl RedisCircuitBreaker {
269    /// Create a breaker that connects to `addr` (e.g. `"127.0.0.1:6379"`).
270    /// `password` is passed to Redis `AUTH` if `Some`.
271    pub fn new(addr: impl Into<String>, password: Option<String>, failure_threshold: u32, recovery_secs: u64) -> Self {
272        RedisCircuitBreaker {
273            conn: Arc::new(RespConn::new(addr, password)),
274            failure_threshold: AtomicU32::new(failure_threshold),
275            recovery_secs: AtomicU64::new(recovery_secs),
276        }
277    }
278
279    /// Build a breaker from environment variables:
280    /// - `RWS_REDIS_HOST` (default `127.0.0.1`)
281    /// - `RWS_REDIS_PORT` (default `6379`)
282    /// - `RWS_REDIS_PASSWORD` (optional)
283    /// - `RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD` (default `5`)
284    /// - `RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS` (default `30`)
285    pub fn from_env() -> Self {
286        let host = std::env::var("RWS_REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".into());
287        let port = std::env::var("RWS_REDIS_PORT").unwrap_or_else(|_| "6379".into());
288        let addr = format!("{}:{}", host, port);
289        let password = std::env::var("RWS_REDIS_PASSWORD").ok();
290        let failure_threshold = std::env::var("RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD")
291            .ok()
292            .and_then(|v| v.parse().ok())
293            .unwrap_or(5);
294        let recovery_secs = std::env::var("RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS")
295            .ok()
296            .and_then(|v| v.parse().ok())
297            .unwrap_or(30);
298        Self::new(addr, password, failure_threshold, recovery_secs)
299    }
300
301    /// Update the thresholds on a live breaker without restarting.
302    pub fn set_limits(&self, failure_threshold: u32, recovery_secs: u64) {
303        self.failure_threshold.store(failure_threshold, Ordering::Relaxed);
304        self.recovery_secs.store(recovery_secs, Ordering::Relaxed);
305    }
306
307    fn redis_key(backend: &str) -> Vec<u8> {
308        format!("rws:cb:{}", backend).into_bytes()
309    }
310
311    fn load(&self, backend: &str) -> std::io::Result<(BreakerState, u32, u64)> {
312        match self.conn.cmd(&[b"GET", &Self::redis_key(backend)])? {
313            RespReply::Bulk(Some(bytes)) => Ok(decode_entry(&bytes)),
314            _ => Ok((BreakerState::Closed, 0, 0)),
315        }
316    }
317
318    fn store(&self, backend: &str, state: &BreakerState, failures: u32, opened_at: u64) -> std::io::Result<()> {
319        let encoded = encode_entry(state, failures, opened_at);
320        self.conn.cmd(&[b"SET", &Self::redis_key(backend), encoded.as_bytes()])?;
321        Ok(())
322    }
323
324    /// Returns `Ok(true)` if a request should be forwarded to `backend`.
325    ///
326    /// Transitions `Open → HalfOpen` when the recovery window has elapsed.
327    /// Returns `Err` if Redis is unreachable — callers decide whether that
328    /// means fail open (treat as available) or fail closed (treat as not).
329    pub fn is_available(&self, backend: &str) -> std::io::Result<bool> {
330        let (state, failures, opened_at) = self.load(backend)?;
331        match state {
332            BreakerState::Closed | BreakerState::HalfOpen => Ok(true),
333            BreakerState::Open => {
334                let recovery = self.recovery_secs.load(Ordering::Relaxed);
335                if now_unix().saturating_sub(opened_at) >= recovery {
336                    self.store(backend, &BreakerState::HalfOpen, failures, 0)?;
337                    Ok(true)
338                } else {
339                    Ok(false)
340                }
341            }
342        }
343    }
344
345    /// Record a successful response for `backend`.
346    ///
347    /// Transitions `HalfOpen → Closed` and resets the failure counter.
348    pub fn record_success(&self, backend: &str) -> std::io::Result<()> {
349        self.store(backend, &BreakerState::Closed, 0, 0)
350    }
351
352    /// Record a failed response for `backend`.
353    ///
354    /// In `Closed` state, increments the counter and opens the circuit when
355    /// `failure_threshold` is reached. In `HalfOpen` state, immediately
356    /// re-opens the circuit and resets the recovery timer.
357    pub fn record_failure(&self, backend: &str) -> std::io::Result<()> {
358        let (state, failures, _) = self.load(backend)?;
359        match state {
360            BreakerState::Closed => {
361                let failures = failures + 1;
362                if failures >= self.failure_threshold.load(Ordering::Relaxed) {
363                    self.store(backend, &BreakerState::Open, failures, now_unix())
364                } else {
365                    self.store(backend, &BreakerState::Closed, failures, 0)
366                }
367            }
368            BreakerState::HalfOpen | BreakerState::Open => {
369                self.store(backend, &BreakerState::Open, failures, now_unix())
370            }
371        }
372    }
373
374    /// Reset `backend` to `Closed` with zero failures.
375    pub fn reset(&self, backend: &str) -> std::io::Result<()> {
376        self.conn.cmd(&[b"DEL", &Self::redis_key(backend)])?;
377        Ok(())
378    }
379
380    /// Return the current state for `backend` (defaults to `Closed` if unseen).
381    pub fn state(&self, backend: &str) -> std::io::Result<BreakerState> {
382        Ok(self.load(backend)?.0)
383    }
384}
385
386fn now_unix() -> u64 {
387    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
388}
389
390/// `"state|failures|opened_at"` — a plain-string encoding chosen so a
391/// backend's whole entry fits in one Redis key read via `GET`/written via
392/// `SET`, rather than a hash needing `HGETALL` (which `redis_protocol`'s
393/// minimal RESP client doesn't decode — it only handles simple/bulk/integer
394/// replies, not arrays).
395fn encode_entry(state: &BreakerState, failures: u32, opened_at: u64) -> String {
396    let state_str = match state {
397        BreakerState::Closed => "closed",
398        BreakerState::Open => "open",
399        BreakerState::HalfOpen => "half_open",
400    };
401    format!("{}|{}|{}", state_str, failures, opened_at)
402}
403
404fn decode_entry(raw: &[u8]) -> (BreakerState, u32, u64) {
405    let text = String::from_utf8_lossy(raw);
406    let mut parts = text.splitn(3, '|');
407    let state = match parts.next() {
408        Some("open") => BreakerState::Open,
409        Some("half_open") => BreakerState::HalfOpen,
410        _ => BreakerState::Closed,
411    };
412    let failures = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
413    let opened_at = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
414    (state, failures, opened_at)
415}
416
417// ── RetryLayer ────────────────────────────────────────────────────────────────
418
419/// Retry middleware.
420///
421/// When the inner application returns a response whose status code is in the
422/// configured list, the request is re-dispatched up to `max_retries` additional
423/// times.  If all attempts return a retryable status the last response is
424/// returned as-is.
425pub struct RetryLayer {
426    max_retries: u32,
427    retry_on: Vec<i16>,
428}
429
430impl RetryLayer {
431    /// Create a `RetryLayer` with defaults: retry on 502, 503, 504 up to 3 times.
432    pub fn new() -> Self {
433        Self { max_retries: 3, retry_on: vec![502, 503, 504] }
434    }
435
436    /// Override the maximum number of retry attempts.
437    pub fn max_retries(mut self, n: u32) -> Self {
438        self.max_retries = n;
439        self
440    }
441
442    /// Override the set of status codes that trigger a retry.
443    pub fn retry_on(mut self, codes: Vec<i16>) -> Self {
444        self.retry_on = codes;
445        self
446    }
447}
448
449impl Default for RetryLayer {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455impl Middleware for RetryLayer {
456    fn handle(
457        &self,
458        request: &Request,
459        connection: &ConnectionInfo,
460        next: &dyn Application,
461    ) -> Result<Response, String> {
462        let mut response = next.execute(request, connection)?;
463        let mut attempts = 0u32;
464        while attempts < self.max_retries && self.retry_on.contains(&response.status_code) {
465            response = next.execute(request, connection)?;
466            attempts += 1;
467        }
468        Ok(response)
469    }
470}