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}