Skip to main content

olai_http/
retry.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! A shared HTTP client implementation incorporating retries
19
20use std::error::Error as StdError;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24use futures::future::BoxFuture;
25use reqwest::header::LOCATION;
26use reqwest::{Request, Response, StatusCode};
27use tracing::info;
28
29use crate::backoff::{Backoff, BackoffConfig};
30use crate::service::HttpService;
31
32/// Retry request error
33#[derive(Debug, thiserror::Error)]
34pub enum Error {
35    #[error(
36        "Received redirect without LOCATION, this normally indicates an incorrectly configured region"
37    )]
38    BareRedirect,
39
40    #[error("Server error, body contains Error, with status {status}: {}", body.as_deref().unwrap_or("No Body"))]
41    Server {
42        status: StatusCode,
43        body: Option<String>,
44    },
45
46    #[error("Client error with status {status}: {}", body.as_deref().unwrap_or("No Body"))]
47    Client {
48        status: StatusCode,
49        body: Option<String>,
50    },
51
52    #[error(
53        "Error after {retries} retries in {elapsed:?}, max_retries:{max_retries}, retry_timeout:{retry_timeout:?}, source:{source}"
54    )]
55    Reqwest {
56        retries: usize,
57        max_retries: usize,
58        elapsed: Duration,
59        retry_timeout: Duration,
60        source: reqwest::Error,
61    },
62}
63
64impl Error {
65    /// Returns the status code associated with this error if any
66    pub fn status(&self) -> Option<StatusCode> {
67        match self {
68            Self::BareRedirect => None,
69            Self::Server { status, .. } => Some(*status),
70            Self::Client { status, .. } => Some(*status),
71            Self::Reqwest { source, .. } => source.status(),
72        }
73    }
74
75    /// Returns the error body if any
76    pub fn body(&self) -> Option<&str> {
77        match self {
78            Self::Client { body, .. } => body.as_deref(),
79            Self::Server { body, .. } => body.as_deref(),
80            Self::BareRedirect => None,
81            Self::Reqwest { .. } => None,
82        }
83    }
84
85    pub fn error(self) -> crate::Error {
86        match self.status() {
87            Some(StatusCode::NOT_FOUND) => crate::Error::NotFound {
88                source: Box::new(self),
89            },
90            Some(StatusCode::NOT_MODIFIED) => crate::Error::NotModified {
91                source: Box::new(self),
92            },
93            Some(StatusCode::PRECONDITION_FAILED) => crate::Error::Precondition {
94                source: Box::new(self),
95            },
96            Some(StatusCode::CONFLICT) => crate::Error::AlreadyExists {
97                source: Box::new(self),
98            },
99            Some(StatusCode::FORBIDDEN) => crate::Error::PermissionDenied {
100                source: Box::new(self),
101            },
102            Some(StatusCode::UNAUTHORIZED) => crate::Error::Unauthenticated {
103                source: Box::new(self),
104            },
105            _ => crate::Error::Generic {
106                source: Box::new(self),
107            },
108        }
109    }
110}
111
112impl From<Error> for std::io::Error {
113    fn from(err: Error) -> Self {
114        use std::io::ErrorKind;
115        match &err {
116            Error::Client {
117                status: StatusCode::NOT_FOUND,
118                ..
119            } => Self::new(ErrorKind::NotFound, err),
120            Error::Client {
121                status: StatusCode::BAD_REQUEST,
122                ..
123            } => Self::new(ErrorKind::InvalidInput, err),
124            Error::Client {
125                status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
126                ..
127            } => Self::new(ErrorKind::PermissionDenied, err),
128            Error::Reqwest { source, .. } if source.is_timeout() => {
129                Self::new(ErrorKind::TimedOut, err)
130            }
131            Error::Reqwest { source, .. } if source.is_connect() => {
132                Self::new(ErrorKind::NotConnected, err)
133            }
134            _ => Self::other(err),
135        }
136    }
137}
138
139pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
140
141/// The configuration for how to respond to request errors.
142///
143/// The following categories of error will be retried:
144///
145/// * 5xx server errors
146/// * Connection errors
147/// * Dropped connections
148/// * Timeouts for [safe] / read-only requests
149///
150/// Requests will be retried up to some limit, using exponential
151/// backoff with jitter. See [`BackoffConfig`] for more information.
152///
153/// ## Default values
154///
155/// | Field           | Default   | Guidance                                                  |
156/// |-----------------|-----------|-----------------------------------------------------------|
157/// | `max_retries`   | `10`      | Reduce to `3`–`5` for latency-sensitive interactive paths |
158/// | `retry_timeout` | `180 s`   | Keep below **5 minutes** so credentials stay valid across all retry attempts |
159/// | `backoff`       | see [`BackoffConfig`] | Exponential with jitter; tune `base` / `deadline` for your SLA |
160///
161/// [safe]: https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
162#[derive(Debug, Clone)]
163pub struct RetryConfig {
164    /// The backoff configuration
165    pub backoff: BackoffConfig,
166
167    /// The maximum number of times to retry a request
168    ///
169    /// Set to 0 to disable retries
170    pub max_retries: usize,
171
172    /// The maximum length of time from the initial request
173    /// after which no further retries will be attempted
174    ///
175    /// This not only bounds the length of time before a server
176    /// error will be surfaced to the application, but also bounds
177    /// the length of time a request's credentials must remain valid.
178    ///
179    /// As requests are retried without renewing credentials or
180    /// regenerating request payloads, this number should be kept
181    /// below 5 minutes to avoid errors due to expired credentials
182    /// and/or request payloads
183    pub retry_timeout: Duration,
184}
185
186impl Default for RetryConfig {
187    fn default() -> Self {
188        Self {
189            backoff: Default::default(),
190            max_retries: 10,
191            retry_timeout: Duration::from_secs(3 * 60),
192        }
193    }
194}
195
196pub(crate) struct RetryableRequest {
197    service: Arc<dyn HttpService>,
198    request: Request,
199
200    max_retries: usize,
201    retry_timeout: Duration,
202    backoff: Backoff,
203
204    sensitive: bool,
205    idempotent: Option<bool>,
206}
207
208impl RetryableRequest {
209    /// Set whether this request is idempotent
210    ///
211    /// An idempotent request will be retried on timeout even if the request
212    /// method is not [safe](https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1)
213    pub(crate) fn idempotent(self, idempotent: bool) -> Self {
214        Self {
215            idempotent: Some(idempotent),
216            ..self
217        }
218    }
219
220    /// Set whether this request contains sensitive data
221    ///
222    /// This will avoid printing out the URL in error messages
223    #[allow(unused)]
224    pub(crate) fn sensitive(self, sensitive: bool) -> Self {
225        Self { sensitive, ..self }
226    }
227
228    pub(crate) async fn send(self) -> Result<Response> {
229        let max_retries = self.max_retries;
230        let retry_timeout = self.retry_timeout;
231        let mut retries = 0;
232        let now = Instant::now();
233
234        let mut backoff = self.backoff;
235        let is_idempotent = self
236            .idempotent
237            .unwrap_or_else(|| self.request.method().is_safe());
238
239        let sanitize_err = move |e: reqwest::Error| match self.sensitive {
240            true => e.without_url(),
241            false => e,
242        };
243
244        loop {
245            let request = self
246                .request
247                .try_clone()
248                .expect("request body must be cloneable");
249
250            match self.service.call(request).await {
251                Ok(r) => match r.error_for_status_ref() {
252                    Ok(_) if r.status().is_success() => {
253                        return Ok(r);
254                    }
255                    Ok(r) if r.status() == StatusCode::NOT_MODIFIED => {
256                        return Err(Error::Client {
257                            body: None,
258                            status: StatusCode::NOT_MODIFIED,
259                        });
260                    }
261                    Ok(r) => {
262                        let is_bare_redirect =
263                            r.status().is_redirection() && !r.headers().contains_key(LOCATION);
264                        return match is_bare_redirect {
265                            true => Err(Error::BareRedirect),
266                            // Not actually sure if this is reachable, but here for completeness
267                            false => Err(Error::Client {
268                                body: None,
269                                status: r.status(),
270                            }),
271                        };
272                    }
273                    Err(e) => {
274                        let e = sanitize_err(e);
275                        let status = r.status();
276                        if retries == max_retries
277                            || now.elapsed() > retry_timeout
278                            || !status.is_server_error()
279                        {
280                            return Err(match status.is_client_error() {
281                                true => match r.text().await {
282                                    Ok(body) => Error::Client {
283                                        body: Some(body).filter(|b| !b.is_empty()),
284                                        status,
285                                    },
286                                    Err(e) => Error::Reqwest {
287                                        retries,
288                                        max_retries,
289                                        elapsed: now.elapsed(),
290                                        retry_timeout,
291                                        source: e,
292                                    },
293                                },
294                                false => Error::Reqwest {
295                                    retries,
296                                    max_retries,
297                                    elapsed: now.elapsed(),
298                                    retry_timeout,
299                                    source: e,
300                                },
301                            });
302                        }
303
304                        let sleep = backoff.next();
305                        retries += 1;
306                        info!(
307                            "Encountered server error, backing off for {} seconds, retry {} of {}: {}",
308                            sleep.as_secs_f32(),
309                            retries,
310                            max_retries,
311                            e,
312                        );
313                        tokio::time::sleep(sleep).await;
314                    }
315                },
316                Err(e) => {
317                    let e = sanitize_err(e);
318
319                    let mut do_retry = false;
320                    if e.is_connect()
321                        || e.is_body()
322                        || (e.is_request() && !e.is_timeout())
323                        || (is_idempotent && e.is_timeout())
324                    {
325                        do_retry = true
326                    } else {
327                        let mut source = e.source();
328                        while let Some(e) = source {
329                            if let Some(e) = e.downcast_ref::<hyper::Error>() {
330                                do_retry = e.is_closed()
331                                    || e.is_incomplete_message()
332                                    || e.is_body_write_aborted()
333                                    || (is_idempotent && e.is_timeout());
334                                break;
335                            }
336                            if let Some(e) = e.downcast_ref::<std::io::Error>() {
337                                if e.kind() == std::io::ErrorKind::TimedOut {
338                                    do_retry = is_idempotent;
339                                } else {
340                                    do_retry = matches!(
341                                        e.kind(),
342                                        std::io::ErrorKind::ConnectionReset
343                                            | std::io::ErrorKind::ConnectionAborted
344                                            | std::io::ErrorKind::BrokenPipe
345                                            | std::io::ErrorKind::UnexpectedEof
346                                    );
347                                }
348                                break;
349                            }
350                            source = e.source();
351                        }
352                    }
353
354                    if retries == max_retries || now.elapsed() > retry_timeout || !do_retry {
355                        return Err(Error::Reqwest {
356                            retries,
357                            max_retries,
358                            elapsed: now.elapsed(),
359                            retry_timeout,
360                            source: e,
361                        });
362                    }
363                    let sleep = backoff.next();
364                    retries += 1;
365                    info!(
366                        "Encountered transport error backing off for {} seconds, retry {} of {}: {}",
367                        sleep.as_secs_f32(),
368                        retries,
369                        max_retries,
370                        e,
371                    );
372                    tokio::time::sleep(sleep).await;
373                }
374            }
375        }
376    }
377}
378
379pub(crate) trait RetryExt {
380    /// Return a [`RetryableRequest`]
381    fn retryable(self, config: &RetryConfig, service: Arc<dyn HttpService>) -> RetryableRequest;
382
383    /// Dispatch a request with the given retry configuration
384    ///
385    /// # Panic
386    ///
387    /// This will panic if the request body is a stream
388    fn send_retry(
389        self,
390        config: &RetryConfig,
391        service: Arc<dyn HttpService>,
392    ) -> BoxFuture<'static, Result<Response>>;
393}
394
395impl RetryExt for reqwest::RequestBuilder {
396    fn retryable(self, config: &RetryConfig, service: Arc<dyn HttpService>) -> RetryableRequest {
397        let (_client, request) = self.build_split();
398        let request = request.expect("request must be valid");
399
400        RetryableRequest {
401            service,
402            request,
403            max_retries: config.max_retries,
404            retry_timeout: config.retry_timeout,
405            backoff: Backoff::new(&config.backoff),
406            sensitive: false,
407            idempotent: None,
408        }
409    }
410
411    fn send_retry(
412        self,
413        config: &RetryConfig,
414        service: Arc<dyn HttpService>,
415    ) -> BoxFuture<'static, Result<Response>> {
416        let request = self.retryable(config, service);
417        Box::pin(async move { request.send().await })
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::RetryConfig;
424    use crate::retry::{Error, RetryExt};
425    use crate::service::ReqwestService;
426
427    use reqwest::{Client, Method, StatusCode};
428    use std::sync::Arc;
429    use std::time::Duration;
430
431    #[tokio::test]
432    async fn test_retry() {
433        let mut server = mockito::Server::new_async().await;
434
435        let retry = RetryConfig {
436            backoff: Default::default(),
437            max_retries: 2,
438            retry_timeout: Duration::from_secs(1000),
439        };
440
441        let client = Client::builder()
442            .timeout(Duration::from_millis(100))
443            .build()
444            .unwrap();
445
446        let service = Arc::new(ReqwestService::new(client.clone()));
447        let server_url = server.url();
448        let do_request = || {
449            client
450                .request(Method::GET, &server_url)
451                .send_retry(&retry, service.clone())
452        };
453
454        // Simple request should work
455        let _mock1 = server
456            .mock("GET", "/")
457            .with_status(200)
458            .with_body("")
459            .create_async()
460            .await;
461
462        let r = do_request().await.unwrap();
463        assert_eq!(r.status(), StatusCode::OK);
464
465        // Returns client errors immediately with status message
466        let _mock2 = server
467            .mock("GET", "/")
468            .with_status(400)
469            .with_body("cupcakes")
470            .create_async()
471            .await;
472
473        let e = do_request().await.unwrap_err();
474        assert_eq!(e.status().unwrap(), StatusCode::BAD_REQUEST);
475        assert_eq!(e.body(), Some("cupcakes"));
476        assert_eq!(
477            e.to_string(),
478            "Client error with status 400 Bad Request: cupcakes"
479        );
480
481        // Handles client errors with no payload
482        let _mock3 = server
483            .mock("GET", "/")
484            .with_status(400)
485            .with_body("")
486            .create_async()
487            .await;
488
489        let e = do_request().await.unwrap_err();
490        assert_eq!(e.status().unwrap(), StatusCode::BAD_REQUEST);
491        assert_eq!(e.body(), None);
492        assert_eq!(
493            e.to_string(),
494            "Client error with status 400 Bad Request: No Body"
495        );
496
497        // Should retry server error request
498        let _mock4 = server
499            .mock("GET", "/")
500            .with_status(502)
501            .with_body("")
502            .create_async()
503            .await;
504
505        let _mock5 = server
506            .mock("GET", "/")
507            .with_status(200)
508            .with_body("")
509            .create_async()
510            .await;
511
512        let r = do_request().await.unwrap();
513        assert_eq!(r.status(), StatusCode::OK);
514
515        // Accepts 204 status code
516        let _mock6 = server
517            .mock("GET", "/")
518            .with_status(204)
519            .with_body("")
520            .create_async()
521            .await;
522
523        let r = do_request().await.unwrap();
524        assert_eq!(r.status(), StatusCode::NO_CONTENT);
525
526        // Follows 302 redirects
527        let _mock7 = server
528            .mock("GET", "/")
529            .with_status(302)
530            .with_header("location", "/foo")
531            .with_body("")
532            .create_async()
533            .await;
534
535        let _mock8 = server
536            .mock("GET", "/foo")
537            .with_status(200)
538            .with_body("")
539            .create_async()
540            .await;
541
542        let r = do_request().await.unwrap();
543        assert_eq!(r.status(), StatusCode::OK);
544        assert_eq!(r.url().path(), "/foo");
545
546        // Handles redirect missing location
547        let _mock_redirect = server
548            .mock("GET", "/")
549            .with_status(302)
550            .with_body("")
551            .create_async()
552            .await;
553
554        let e = do_request().await.unwrap_err();
555        assert!(matches!(e, Error::BareRedirect));
556        assert_eq!(
557            e.to_string(),
558            "Received redirect without LOCATION, this normally indicates an incorrectly configured region"
559        );
560
561        // Test timeout scenarios with delays
562        let _timeout_mock1 = server
563            .mock("GET", "/")
564            .with_status(200)
565            .with_body("")
566            .create_async()
567            .await;
568
569        let _timeout_mock2 = server
570            .mock("GET", "/")
571            .with_status(200)
572            .with_body("")
573            .create_async()
574            .await;
575
576        do_request().await.unwrap();
577
578        // Test sensitive URL handling
579        let sensitive_url = format!("{}/SENSITIVE", server_url);
580        let _sensitive_mock = server
581            .mock("GET", "/SENSITIVE")
582            .with_status(502)
583            .with_body("ignored")
584            .create_async()
585            .await;
586
587        let res = client
588            .request(Method::GET, &sensitive_url)
589            .send_retry(&retry, service.clone())
590            .await;
591        let err = res.unwrap_err().to_string();
592        assert!(err.contains("SENSITIVE"), "{err}");
593
594        // Test sensitive request with retryable that strips URL
595        let _sensitive_mock2 = server
596            .mock("GET", "/SENSITIVE")
597            .with_status(502)
598            .with_body("ignored")
599            .create_async()
600            .await;
601
602        let req = client
603            .request(Method::GET, &sensitive_url)
604            .retryable(&retry, service.clone())
605            .sensitive(true);
606        let err = req.send().await.unwrap_err().to_string();
607        assert!(!err.contains("SENSITIVE"), "{err}");
608    }
609}