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