Skip to main content

rws_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//! # extern crate rws_server as rust_web_server;
39//! use rust_web_server::app::App;
40//! use rust_web_server::core::New;
41//! use rust_web_server::circuit_breaker::RetryLayer;
42//! use rust_web_server::middleware::WithMiddleware;
43//!
44//! let app = WithMiddleware::new(App::new())
45//!     .wrap(RetryLayer::new().max_retries(2));
46//! ```
47
48#[cfg(test)]
49mod tests;
50
51use std::collections::HashMap;
52use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
53use std::sync::{Arc, Mutex, OnceLock};
54use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
55
56use crate::application::Application;
57use crate::middleware::Middleware;
58use crate::redis_protocol::{RespConn, RespReply};
59use crate::request::Request;
60use crate::response::Response;
61use crate::server::ConnectionInfo;
62
63// ── BreakerState ─────────────────────────────────────────────────────────────
64
65/// Current state of a single backend's circuit breaker.
66#[derive(Debug, Clone, PartialEq)]
67pub enum BreakerState {
68    /// Healthy — requests are forwarded and failures are counted.
69    Closed,
70    /// Unhealthy — requests are rejected until the recovery window expires.
71    Open,
72    /// Probing — one request is let through to test backend health.
73    HalfOpen,
74}
75
76// ── BackendEntry ──────────────────────────────────────────────────────────────
77
78struct BackendEntry {
79    state: BreakerState,
80    failures: u32,
81    opened_at: Option<Instant>,
82    /// Probes currently let through while `state == HalfOpen`. Capped by
83    /// [`CircuitBreaker::max_half_open_probes`] so a burst of concurrent
84    /// requests arriving the instant a backend transitions to `HalfOpen`
85    /// doesn't all land as "the" trial request at once.
86    half_open_in_flight: u32,
87}
88
89impl BackendEntry {
90    fn new() -> Self {
91        Self { state: BreakerState::Closed, failures: 0, opened_at: None, half_open_in_flight: 0 }
92    }
93}
94
95// ── CircuitBreaker ────────────────────────────────────────────────────────────
96
97/// Per-backend circuit breaker.
98///
99/// # Concurrency
100///
101/// `CircuitBreaker` is not `Sync` on its own — wrap it in a [`Mutex`] for
102/// shared use across threads (see [`global()`]).
103pub struct CircuitBreaker {
104    backends: HashMap<String, BackendEntry>,
105    failure_threshold: u32,
106    recovery: Duration,
107    max_half_open_probes: u32,
108}
109
110impl CircuitBreaker {
111    /// Create a new circuit breaker.
112    ///
113    /// * `failure_threshold` — how many consecutive failures open the circuit.
114    /// * `recovery_secs` — how long the circuit stays Open before testing again.
115    ///
116    /// Defaults to letting exactly one probe through while `HalfOpen` — see
117    /// [`CircuitBreaker::max_half_open_probes`] to change that.
118    pub fn new(failure_threshold: u32, recovery_secs: u64) -> Self {
119        Self {
120            backends: HashMap::new(),
121            failure_threshold,
122            recovery: Duration::from_secs(recovery_secs),
123            max_half_open_probes: 1,
124        }
125    }
126
127    /// Override how many concurrent probe requests are let through while a
128    /// backend is `HalfOpen` (default: 1). Chainable — call before the
129    /// breaker is put behind a shared `Mutex`/`Arc`.
130    pub fn max_half_open_probes(mut self, n: u32) -> Self {
131        self.max_half_open_probes = n.max(1);
132        self
133    }
134
135    /// Returns `true` if a request should be forwarded to `backend`.
136    ///
137    /// Transitions `Open → HalfOpen` when the recovery window has elapsed.
138    /// While `HalfOpen`, at most `max_half_open_probes` concurrent calls
139    /// return `true` — further calls are rejected like `Open` until the
140    /// in-flight probe(s) resolve via `record_success`/`record_failure`.
141    pub fn is_available(&mut self, backend: &str) -> bool {
142        let max_probes = self.max_half_open_probes;
143        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
144        match entry.state {
145            BreakerState::Closed => true,
146            BreakerState::HalfOpen => {
147                if entry.half_open_in_flight < max_probes {
148                    entry.half_open_in_flight += 1;
149                    true
150                } else {
151                    false
152                }
153            }
154            BreakerState::Open => {
155                if let Some(opened_at) = entry.opened_at {
156                    if opened_at.elapsed() >= self.recovery {
157                        entry.state = BreakerState::HalfOpen;
158                        entry.opened_at = None;
159                        entry.half_open_in_flight = 1;
160                        return true;
161                    }
162                }
163                false
164            }
165        }
166    }
167
168    /// Record a successful response for `backend`.
169    ///
170    /// Transitions `HalfOpen → Closed` and resets the failure counter and
171    /// the in-flight half-open probe count.
172    pub fn record_success(&mut self, backend: &str) {
173        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
174        entry.state = BreakerState::Closed;
175        entry.failures = 0;
176        entry.opened_at = None;
177        entry.half_open_in_flight = 0;
178    }
179
180    /// Record a failed response for `backend`.
181    ///
182    /// In `Closed` state, increments the counter and opens the circuit when
183    /// `failure_threshold` is reached.  In `HalfOpen` state, immediately
184    /// re-opens the circuit and resets the recovery timer.
185    pub fn record_failure(&mut self, backend: &str) {
186        let threshold = self.failure_threshold;
187        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
188        match entry.state {
189            BreakerState::Closed => {
190                entry.failures += 1;
191                if entry.failures >= threshold {
192                    entry.state = BreakerState::Open;
193                    entry.opened_at = Some(Instant::now());
194                }
195            }
196            BreakerState::HalfOpen => {
197                entry.state = BreakerState::Open;
198                entry.opened_at = Some(Instant::now());
199                entry.half_open_in_flight = 0;
200            }
201            BreakerState::Open => {
202                // Already open; refresh the timer.
203                entry.opened_at = Some(Instant::now());
204            }
205        }
206    }
207
208    /// Reset `backend` to `Closed` with zero failures.
209    pub fn reset(&mut self, backend: &str) {
210        let entry = self.backends.entry(backend.to_string()).or_insert_with(BackendEntry::new);
211        entry.state = BreakerState::Closed;
212        entry.failures = 0;
213        entry.opened_at = None;
214        entry.half_open_in_flight = 0;
215    }
216
217    /// Return the current state for `backend` (defaults to `Closed` if unseen).
218    pub fn state(&self, backend: &str) -> BreakerState {
219        self.backends
220            .get(backend)
221            .map(|e| e.state.clone())
222            .unwrap_or(BreakerState::Closed)
223    }
224
225    /// Snapshot of every backend this breaker has ever seen, with its
226    /// current state. Powers the `rws_circuit_breaker_state{backend}`
227    /// metric (`crate::metrics::prometheus_text`) — there is no
228    /// `RedisCircuitBreaker` equivalent, since enumerating keys isn't
229    /// something the minimal hand-rolled RESP client supports (no
230    /// `SCAN`/`KEYS` array-reply decoding).
231    pub fn all_states(&self) -> Vec<(String, BreakerState)> {
232        self.backends.iter().map(|(k, v)| (k.clone(), v.state.clone())).collect()
233    }
234}
235
236// ── global() ─────────────────────────────────────────────────────────────────
237
238static GLOBAL_BREAKER: OnceLock<Mutex<CircuitBreaker>> = OnceLock::new();
239
240/// Return the process-wide default circuit breaker (threshold=5, recovery=30 s).
241///
242/// Acquire the mutex before calling any `CircuitBreaker` method:
243///
244/// ```rust
245/// # extern crate rws_server as rust_web_server;
246/// use rust_web_server::circuit_breaker;
247///
248/// let available = circuit_breaker::global().lock().unwrap().is_available("backend-a:8080");
249/// ```
250pub fn global() -> &'static Mutex<CircuitBreaker> {
251    GLOBAL_BREAKER.get_or_init(|| Mutex::new(CircuitBreaker::new(5, 30)))
252}
253
254// ── RedisCircuitBreaker ─────────────────────────────────────────────────────────
255
256/// Per-backend circuit breaker, persisted in Redis.
257///
258/// Same Closed → Open → HalfOpen state machine as [`CircuitBreaker`], and the
259/// same method names, but every operation is a synchronous Redis round trip
260/// (via the shared [`crate::redis_protocol`] RESP client) instead of an
261/// in-memory `HashMap` update — so state survives a process restart, and is
262/// shared across every `rws` instance pointed at the same Redis server.
263///
264/// # Why Redis, not the model layer
265///
266/// The model layer (`DbPool`, `sqlx`) is `async fn`-only, while
267/// `CircuitBreaker`'s methods and `Middleware::handle` (what [`RetryLayer`]
268/// implements) are both synchronous — the identical async/sync mismatch that
269/// left `SqliteRateLimiter` unbuilt after [`crate::rate_limit::RedisRateLimiter`]
270/// shipped. Redis, reached over a plain blocking `TcpStream`, stays fully
271/// synchronous and drops into the same call sites `CircuitBreaker` already
272/// has, with no new Cargo dependency.
273///
274/// # Consistency
275///
276/// Each operation is a read-then-write (`GET` then `SET`) against one Redis
277/// key per backend — not a single atomic command. Two `rws` instances racing
278/// to record a failure for the same backend at the same instant can lose one
279/// of the two increments. This is a deliberate simplification: unlike a rate
280/// limit (a hard resource/security boundary, where `RedisRateLimiter` uses
281/// genuinely atomic `INCR` for exactly this reason), a circuit breaker is a
282/// self-healing heuristic where opening one failure late — or one request
283/// later than a perfectly-synchronized count would — has no real consequence.
284///
285/// # Example
286///
287/// ```rust,no_run
288/// # extern crate rws_server as rust_web_server;
289/// use rust_web_server::circuit_breaker::RedisCircuitBreaker;
290///
291/// let breaker = RedisCircuitBreaker::new("127.0.0.1:6379", None, 5, 30);
292///
293/// match breaker.is_available("backend-a:8080") {
294///     Ok(true) => { /* forward the request */ }
295///     Ok(false) => { /* short-circuit — return 503 without contacting the backend */ }
296///     Err(e) => { /* Redis unreachable — decide fail-open vs fail-closed yourself */ }
297/// }
298/// ```
299pub struct RedisCircuitBreaker {
300    conn: Arc<RespConn>,
301    failure_threshold: AtomicU32,
302    recovery_secs: AtomicU64,
303    max_half_open_probes: AtomicU32,
304}
305
306impl Clone for RedisCircuitBreaker {
307    fn clone(&self) -> Self {
308        RedisCircuitBreaker {
309            conn: Arc::clone(&self.conn),
310            failure_threshold: AtomicU32::new(self.failure_threshold.load(Ordering::Relaxed)),
311            recovery_secs: AtomicU64::new(self.recovery_secs.load(Ordering::Relaxed)),
312            max_half_open_probes: AtomicU32::new(self.max_half_open_probes.load(Ordering::Relaxed)),
313        }
314    }
315}
316
317impl RedisCircuitBreaker {
318    /// Create a breaker that connects to `addr` (e.g. `"127.0.0.1:6379"`).
319    /// `password` is passed to Redis `AUTH` if `Some`. Defaults to letting
320    /// exactly one probe through while `HalfOpen` — see
321    /// [`RedisCircuitBreaker::set_max_half_open_probes`] to change that.
322    pub fn new(addr: impl Into<String>, password: Option<String>, failure_threshold: u32, recovery_secs: u64) -> Self {
323        RedisCircuitBreaker {
324            conn: Arc::new(RespConn::new(addr, password)),
325            failure_threshold: AtomicU32::new(failure_threshold),
326            recovery_secs: AtomicU64::new(recovery_secs),
327            max_half_open_probes: AtomicU32::new(1),
328        }
329    }
330
331    /// Build a breaker from environment variables:
332    /// - `RWS_REDIS_HOST` (default `127.0.0.1`)
333    /// - `RWS_REDIS_PORT` (default `6379`)
334    /// - `RWS_REDIS_PASSWORD` (optional)
335    /// - `RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD` (default `5`)
336    /// - `RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS` (default `30`)
337    /// - `RWS_CONFIG_CIRCUIT_BREAKER_MAX_HALF_OPEN_PROBES` (default `1`)
338    pub fn from_env() -> Self {
339        let host = std::env::var("RWS_REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".into());
340        let port = std::env::var("RWS_REDIS_PORT").unwrap_or_else(|_| "6379".into());
341        let addr = format!("{}:{}", host, port);
342        let password = std::env::var("RWS_REDIS_PASSWORD").ok();
343        let failure_threshold = std::env::var("RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD")
344            .ok()
345            .and_then(|v| v.parse().ok())
346            .unwrap_or(5);
347        let recovery_secs = std::env::var("RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS")
348            .ok()
349            .and_then(|v| v.parse().ok())
350            .unwrap_or(30);
351        let breaker = Self::new(addr, password, failure_threshold, recovery_secs);
352        if let Some(n) = std::env::var("RWS_CONFIG_CIRCUIT_BREAKER_MAX_HALF_OPEN_PROBES").ok().and_then(|v| v.parse().ok()) {
353            breaker.set_max_half_open_probes(n);
354        }
355        breaker
356    }
357
358    /// Update the thresholds on a live breaker without restarting.
359    pub fn set_limits(&self, failure_threshold: u32, recovery_secs: u64) {
360        self.failure_threshold.store(failure_threshold, Ordering::Relaxed);
361        self.recovery_secs.store(recovery_secs, Ordering::Relaxed);
362    }
363
364    /// Override how many concurrent probe requests are let through while a
365    /// backend is `HalfOpen` (default: 1) — on a live breaker, no restart
366    /// required.
367    pub fn set_max_half_open_probes(&self, n: u32) {
368        self.max_half_open_probes.store(n.max(1), Ordering::Relaxed);
369    }
370
371    fn redis_key(backend: &str) -> Vec<u8> {
372        format!("rws:cb:{}", backend).into_bytes()
373    }
374
375    fn load(&self, backend: &str) -> std::io::Result<(BreakerState, u32, u64, u32)> {
376        match self.conn.cmd(&[b"GET", &Self::redis_key(backend)])? {
377            RespReply::Bulk(Some(bytes)) => Ok(decode_entry(&bytes)),
378            _ => Ok((BreakerState::Closed, 0, 0, 0)),
379        }
380    }
381
382    fn store(&self, backend: &str, state: &BreakerState, failures: u32, opened_at: u64, half_open_in_flight: u32) -> std::io::Result<()> {
383        let encoded = encode_entry(state, failures, opened_at, half_open_in_flight);
384        self.conn.cmd(&[b"SET", &Self::redis_key(backend), encoded.as_bytes()])?;
385        Ok(())
386    }
387
388    /// Returns `Ok(true)` if a request should be forwarded to `backend`.
389    ///
390    /// Transitions `Open → HalfOpen` when the recovery window has elapsed.
391    /// While `HalfOpen`, at most `max_half_open_probes` concurrent calls
392    /// return `Ok(true)` — this is a read-then-write against one Redis key
393    /// (not atomic), the same deliberate simplification already documented
394    /// above for this type, so a race between two instances can very rarely
395    /// let one extra probe through; it cannot let an unbounded burst through.
396    /// Returns `Err` if Redis is unreachable — callers decide whether that
397    /// means fail open (treat as available) or fail closed (treat as not).
398    pub fn is_available(&self, backend: &str) -> std::io::Result<bool> {
399        let (state, failures, opened_at, half_open_in_flight) = self.load(backend)?;
400        match state {
401            BreakerState::Closed => Ok(true),
402            BreakerState::HalfOpen => {
403                let max_probes = self.max_half_open_probes.load(Ordering::Relaxed);
404                if half_open_in_flight < max_probes {
405                    self.store(backend, &BreakerState::HalfOpen, failures, opened_at, half_open_in_flight + 1)?;
406                    Ok(true)
407                } else {
408                    Ok(false)
409                }
410            }
411            BreakerState::Open => {
412                let recovery = self.recovery_secs.load(Ordering::Relaxed);
413                if now_unix().saturating_sub(opened_at) >= recovery {
414                    self.store(backend, &BreakerState::HalfOpen, failures, 0, 1)?;
415                    Ok(true)
416                } else {
417                    Ok(false)
418                }
419            }
420        }
421    }
422
423    /// Record a successful response for `backend`.
424    ///
425    /// Transitions `HalfOpen → Closed` and resets the failure counter and
426    /// the in-flight half-open probe count.
427    pub fn record_success(&self, backend: &str) -> std::io::Result<()> {
428        self.store(backend, &BreakerState::Closed, 0, 0, 0)
429    }
430
431    /// Record a failed response for `backend`.
432    ///
433    /// In `Closed` state, increments the counter and opens the circuit when
434    /// `failure_threshold` is reached. In `HalfOpen` state, immediately
435    /// re-opens the circuit and resets the recovery timer.
436    pub fn record_failure(&self, backend: &str) -> std::io::Result<()> {
437        let (state, failures, _, _) = self.load(backend)?;
438        match state {
439            BreakerState::Closed => {
440                let failures = failures + 1;
441                if failures >= self.failure_threshold.load(Ordering::Relaxed) {
442                    self.store(backend, &BreakerState::Open, failures, now_unix(), 0)
443                } else {
444                    self.store(backend, &BreakerState::Closed, failures, 0, 0)
445                }
446            }
447            BreakerState::HalfOpen | BreakerState::Open => {
448                self.store(backend, &BreakerState::Open, failures, now_unix(), 0)
449            }
450        }
451    }
452
453    /// Reset `backend` to `Closed` with zero failures.
454    pub fn reset(&self, backend: &str) -> std::io::Result<()> {
455        self.conn.cmd(&[b"DEL", &Self::redis_key(backend)])?;
456        Ok(())
457    }
458
459    /// Return the current state for `backend` (defaults to `Closed` if unseen).
460    pub fn state(&self, backend: &str) -> std::io::Result<BreakerState> {
461        Ok(self.load(backend)?.0)
462    }
463}
464
465fn now_unix() -> u64 {
466    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
467}
468
469/// `"state|failures|opened_at|half_open_in_flight"` — a plain-string encoding
470/// chosen so a backend's whole entry fits in one Redis key read via
471/// `GET`/written via `SET`, rather than a hash needing `HGETALL` (which
472/// `redis_protocol`'s minimal RESP client doesn't decode — it only handles
473/// simple/bulk/integer replies, not arrays).
474fn encode_entry(state: &BreakerState, failures: u32, opened_at: u64, half_open_in_flight: u32) -> String {
475    let state_str = match state {
476        BreakerState::Closed => "closed",
477        BreakerState::Open => "open",
478        BreakerState::HalfOpen => "half_open",
479    };
480    format!("{}|{}|{}|{}", state_str, failures, opened_at, half_open_in_flight)
481}
482
483fn decode_entry(raw: &[u8]) -> (BreakerState, u32, u64, u32) {
484    let text = String::from_utf8_lossy(raw);
485    let mut parts = text.splitn(4, '|');
486    let state = match parts.next() {
487        Some("open") => BreakerState::Open,
488        Some("half_open") => BreakerState::HalfOpen,
489        _ => BreakerState::Closed,
490    };
491    let failures = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
492    let opened_at = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
493    let half_open_in_flight = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
494    (state, failures, opened_at, half_open_in_flight)
495}
496
497// ── Breaker trait (ReverseProxy auto-wiring) ─────────────────────────────────
498
499/// Object-safe interface implemented by both `Mutex<CircuitBreaker>` and
500/// [`RedisCircuitBreaker`], so a consumer like
501/// `proxy::ReverseProxy::with_circuit_breaker` can accept either
502/// breaker kind through one `Arc<dyn Breaker>` without a generic parameter
503/// leaking into its own type.
504///
505/// `RedisCircuitBreaker`'s `Err` (Redis unreachable) is treated as *available*
506/// — failing open, not closed. A breaker that can't be reached is a worse
507/// single point of failure than occasionally skipping the check it exists to
508/// perform; callers who need fail-closed semantics should call
509/// `RedisCircuitBreaker::is_available` directly and decide for themselves, as
510/// its own docs already describe.
511pub trait Breaker: Send + Sync {
512    /// Returns `true` if a request should be forwarded to `backend`.
513    fn is_available(&self, backend: &str) -> bool;
514    /// Record a successful response for `backend`.
515    fn record_success(&self, backend: &str);
516    /// Record a failed response for `backend`.
517    fn record_failure(&self, backend: &str);
518}
519
520impl Breaker for Mutex<CircuitBreaker> {
521    fn is_available(&self, backend: &str) -> bool {
522        self.lock().unwrap().is_available(backend)
523    }
524    fn record_success(&self, backend: &str) {
525        self.lock().unwrap().record_success(backend);
526    }
527    fn record_failure(&self, backend: &str) {
528        self.lock().unwrap().record_failure(backend);
529    }
530}
531
532impl Breaker for RedisCircuitBreaker {
533    fn is_available(&self, backend: &str) -> bool {
534        RedisCircuitBreaker::is_available(self, backend).unwrap_or(true)
535    }
536    fn record_success(&self, backend: &str) {
537        let _ = RedisCircuitBreaker::record_success(self, backend);
538    }
539    fn record_failure(&self, backend: &str) {
540        let _ = RedisCircuitBreaker::record_failure(self, backend);
541    }
542}
543
544/// Lets `circuit_breaker::global()` (which returns `&'static Mutex<CircuitBreaker>`,
545/// not an owned `Mutex<CircuitBreaker>`) be used directly as a `Breaker`:
546/// `Arc::new(circuit_breaker::global())` then coerces to `Arc<dyn Breaker>`.
547impl<T: Breaker + ?Sized> Breaker for &T {
548    fn is_available(&self, backend: &str) -> bool {
549        (**self).is_available(backend)
550    }
551    fn record_success(&self, backend: &str) {
552        (**self).record_success(backend);
553    }
554    fn record_failure(&self, backend: &str) {
555        (**self).record_failure(backend);
556    }
557}
558
559// ── RetryLayer ────────────────────────────────────────────────────────────────
560
561/// Retry middleware.
562///
563/// When the inner application returns a response whose status code is in the
564/// configured list, the request is re-dispatched up to `max_retries` additional
565/// times.  If all attempts return a retryable status the last response is
566/// returned as-is.
567pub struct RetryLayer {
568    max_retries: u32,
569    retry_on: Vec<i16>,
570}
571
572impl RetryLayer {
573    /// Create a `RetryLayer` with defaults: retry on 502, 503, 504 up to 3 times.
574    pub fn new() -> Self {
575        Self { max_retries: 3, retry_on: vec![502, 503, 504] }
576    }
577
578    /// Override the maximum number of retry attempts.
579    pub fn max_retries(mut self, n: u32) -> Self {
580        self.max_retries = n;
581        self
582    }
583
584    /// Override the set of status codes that trigger a retry.
585    pub fn retry_on(mut self, codes: Vec<i16>) -> Self {
586        self.retry_on = codes;
587        self
588    }
589}
590
591impl Default for RetryLayer {
592    fn default() -> Self {
593        Self::new()
594    }
595}
596
597impl Middleware for RetryLayer {
598    fn handle(
599        &self,
600        request: &Request,
601        connection: &ConnectionInfo,
602        next: &dyn Application,
603    ) -> Result<Response, String> {
604        let mut response = next.execute(request, connection)?;
605        let mut attempts = 0u32;
606        while attempts < self.max_retries && self.retry_on.contains(&response.status_code) {
607            response = next.execute(request, connection)?;
608            attempts += 1;
609        }
610        Ok(response)
611    }
612}