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}