fetch_kit/middleware/
retry.rs1use 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#[derive(Debug, Clone)]
33pub struct RetryMiddleware {
34 config: RetryConfig,
35}
36
37impl RetryMiddleware {
38 pub fn new(config: RetryConfig) -> Self {
40 Self { config }
41 }
42
43 pub fn with_defaults() -> Self {
46 Self::new(RetryConfig::default())
47 }
48}
49
50pub 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
59pub 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
70enum AttemptFailure {
73 Transient(Error),
75 RetryableStatus(Response),
78 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 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 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 *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)] 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}