Skip to main content

fetch_kit/middleware/
retry.rs

1//! Retry middleware — estate loop-retry semantics (`loop-retry` crate)
2//! applied per middleware attempt.
3//!
4//! Retryable outcomes are transport timeouts/connect errors and retryable
5//! response statuses (408, 429, 5xx except 501). Middleware errors are
6//! **never** retried: a downstream short-circuit such as
7//! [`crate::FetchError::CircuitOpen`] surfaces immediately.
8//!
9//! When retries are exhausted, the final outcome is returned as-is — a
10//! retryable-status response is returned unchanged (callers decide what to
11//! do with the status), matching reqwest-retry's transient-retry behavior
12//! that fetch-kit 0.1.x defaulted to.
13//!
14//! Backoff delegates to `loop_retry::RetryConfig` (exponential growth with
15//! a multiplier of 2.0, capped at `max_delay`, optional 10% jitter), so
16//! the timing semantics are the estate's single source of truth.
17
18use std::fmt;
19
20use async_trait::async_trait;
21use http::Extensions;
22use loop_retry::{IsRetryable, RetryConfig, RetryError, with_backoff};
23use reqwest::{Request, Response, StatusCode};
24
25use super::{Error, Middleware, Next, Result};
26
27/// Retry middleware wrapping the remainder of the chain in the estate's
28/// `loop-retry` backoff loop.
29///
30/// Defaults used by [`crate::ClientBuilder`]: 3 retries, 500ms initial
31/// delay, 30s cap, jitter on.
32#[derive(Debug, Clone)]
33pub struct RetryMiddleware {
34    config: RetryConfig,
35}
36
37impl RetryMiddleware {
38    /// Create a retry middleware with the given loop-retry configuration.
39    pub fn new(config: RetryConfig) -> Self {
40        Self { config }
41    }
42
43    /// Create a retry middleware with `loop_retry::RetryConfig::default()`
44    /// (3 retries, 100ms initial, 5s cap, jitter on).
45    pub fn with_defaults() -> Self {
46        Self::new(RetryConfig::default())
47    }
48}
49
50/// Whether `status` is retried by [`RetryMiddleware`]:
51/// 408 Request Timeout, 429 Too Many Requests, and 5xx except 501 Not
52/// Implemented.
53pub fn is_retryable_status(status: StatusCode) -> bool {
54    status == StatusCode::REQUEST_TIMEOUT
55        || status == StatusCode::TOO_MANY_REQUESTS
56        || (status.is_server_error() && status != StatusCode::NOT_IMPLEMENTED)
57}
58
59/// Whether `err` is retried by [`RetryMiddleware`]: transport-level
60/// timeouts and connection failures. Middleware errors are permanent by
61/// design — a short-circuit (e.g. an open circuit breaker) must not be
62/// masked by retries.
63pub fn is_retryable_error(err: &Error) -> bool {
64    match err {
65        Error::Reqwest(e) => e.is_timeout() || e.is_connect(),
66        Error::Middleware(_) => false,
67    }
68}
69
70/// The classification of one attempt, fed to `loop_retry`'s
71/// `IsRetryable` to drive the backoff loop.
72enum AttemptFailure {
73    /// Transport timeout/connect error — retryable.
74    Transient(Error),
75    /// Response with a retryable status — retry until success or
76    /// exhaustion; the final response is returned unchanged.
77    RetryableStatus(Response),
78    /// Anything else — surfaced immediately, never retried.
79    Permanent(Error),
80}
81
82impl fmt::Display for AttemptFailure {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::Transient(e) | Self::Permanent(e) => write!(f, "{e}"),
86            Self::RetryableStatus(resp) => write!(f, "retryable status {}", resp.status()),
87        }
88    }
89}
90
91impl IsRetryable for AttemptFailure {
92    fn is_retryable(&self) -> bool {
93        match self {
94            Self::Transient(_) | Self::RetryableStatus(_) => true,
95            Self::Permanent(_) => false,
96        }
97    }
98}
99
100#[async_trait]
101impl Middleware for RetryMiddleware {
102    async fn handle(
103        &self,
104        req: Request,
105        extensions: &mut Extensions,
106        next: Next<'_>,
107    ) -> Result<Response> {
108        // Own the request in a slot so each loop iteration can hand an
109        // owned clone to the (required-'static) attempt future, and own
110        // the shared per-request extensions so each attempt runs against
111        // an independent snapshot.
112        let mut slot = Some(req);
113        let shared = std::mem::take(extensions);
114
115        let outcome = with_backoff(&self.config, || {
116            let attempt_req = slot.take();
117            // Leave a clone in the slot for the next iteration (None for
118            // streaming bodies, which cannot be retried).
119            slot = attempt_req.as_ref().and_then(Request::try_clone);
120            let snapshot = shared.clone();
121            let next = next.clone();
122            async move {
123                let Some(req) = attempt_req else {
124                    return Err(AttemptFailure::Permanent(Error::Middleware(
125                        "request body is not cloneable; cannot retry".into(),
126                    )));
127                };
128                let mut snapshot = snapshot;
129                match next.run(req, &mut snapshot).await {
130                    Ok(resp) if is_retryable_status(resp.status()) => {
131                        Err(AttemptFailure::RetryableStatus(resp))
132                    }
133                    Ok(resp) => Ok(resp),
134                    Err(e) if is_retryable_error(&e) => Err(AttemptFailure::Transient(e)),
135                    Err(e) => Err(AttemptFailure::Permanent(e)),
136                }
137            }
138        })
139        .await;
140
141        // Restore the (initial) shared extensions for outer middleware:
142        // attempts ran on independent snapshots, so inner mutations made
143        // during the retry loop are deliberately not propagated outward.
144        *extensions = shared;
145
146        match outcome {
147            Ok(resp) => Ok(resp),
148            Err(RetryError::FinalError(failure, _attempts)) => match failure {
149                AttemptFailure::RetryableStatus(resp) => Ok(resp),
150                AttemptFailure::Transient(e) | AttemptFailure::Permanent(e) => Err(e),
151            },
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    #![allow(clippy::unwrap_used, clippy::expect_used)] // test assertions unwrap by design
159    use super::*;
160
161    #[test]
162    fn retryable_statuses_match_reqwest_retry_defaults() {
163        assert!(is_retryable_status(StatusCode::REQUEST_TIMEOUT));
164        assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
165        assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR));
166        assert!(is_retryable_status(StatusCode::BAD_GATEWAY));
167        assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE));
168        assert!(!is_retryable_status(StatusCode::NOT_IMPLEMENTED));
169        assert!(!is_retryable_status(StatusCode::NOT_FOUND));
170        assert!(!is_retryable_status(StatusCode::UNAUTHORIZED));
171        assert!(!is_retryable_status(StatusCode::OK));
172    }
173
174    #[test]
175    fn middleware_errors_are_never_retryable() {
176        let middleware_err = Error::Middleware("boom".into());
177        assert!(!is_retryable_error(&middleware_err));
178    }
179
180    #[test]
181    fn attempt_failure_classification_and_display() {
182        let permanent = AttemptFailure::Permanent(Error::Middleware("boom".into()));
183        assert!(permanent.to_string().contains("boom"));
184        assert!(!permanent.is_retryable());
185        assert!(AttemptFailure::Transient(Error::Middleware("x".into())).is_retryable());
186    }
187}