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    /// A non-retryable transport failure surfaced by the [`HttpService`], for
64    /// example when a spawned I/O task is cancelled during runtime shutdown.
65    #[error("Transport error: {source}")]
66    Transport {
67        source: Box<dyn StdError + Send + Sync + 'static>,
68    },
69}
70
71impl Error {
72    /// Returns the status code associated with this error if any
73    pub fn status(&self) -> Option<StatusCode> {
74        match self {
75            Self::BareRedirect => None,
76            Self::Server { status, .. } => Some(*status),
77            Self::Client { status, .. } => Some(*status),
78            Self::Reqwest { source, .. } => source.status(),
79            Self::Transport { .. } => None,
80        }
81    }
82
83    /// Returns the error body if any
84    pub fn body(&self) -> Option<&str> {
85        match self {
86            Self::Client { body, .. } => body.as_deref(),
87            Self::Server { body, .. } => body.as_deref(),
88            Self::BareRedirect => None,
89            Self::Reqwest { .. } => None,
90            Self::Transport { .. } => None,
91        }
92    }
93
94    pub fn error(self) -> crate::Error {
95        match self.status() {
96            Some(StatusCode::NOT_FOUND) => crate::Error::NotFound {
97                source: Box::new(self),
98            },
99            Some(StatusCode::NOT_MODIFIED) => crate::Error::NotModified {
100                source: Box::new(self),
101            },
102            Some(StatusCode::PRECONDITION_FAILED) => crate::Error::Precondition {
103                source: Box::new(self),
104            },
105            Some(StatusCode::CONFLICT) => crate::Error::AlreadyExists {
106                source: Box::new(self),
107            },
108            Some(StatusCode::FORBIDDEN) => crate::Error::PermissionDenied {
109                source: Box::new(self),
110            },
111            Some(StatusCode::UNAUTHORIZED) => crate::Error::Unauthenticated {
112                source: Box::new(self),
113            },
114            _ => crate::Error::Generic {
115                source: Box::new(self),
116            },
117        }
118    }
119}
120
121impl From<Error> for std::io::Error {
122    fn from(err: Error) -> Self {
123        use std::io::ErrorKind;
124        match &err {
125            Error::Client {
126                status: StatusCode::NOT_FOUND,
127                ..
128            } => Self::new(ErrorKind::NotFound, err),
129            Error::Client {
130                status: StatusCode::BAD_REQUEST,
131                ..
132            } => Self::new(ErrorKind::InvalidInput, err),
133            Error::Client {
134                status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
135                ..
136            } => Self::new(ErrorKind::PermissionDenied, err),
137            Error::Reqwest { source, .. } if source.is_timeout() => {
138                Self::new(ErrorKind::TimedOut, err)
139            }
140            Error::Reqwest { source, .. } if source.is_connect() => {
141                Self::new(ErrorKind::NotConnected, err)
142            }
143            _ => Self::other(err),
144        }
145    }
146}
147
148pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
149
150/// The configuration for how to respond to request errors.
151///
152/// The following categories of error will be retried:
153///
154/// * 5xx server errors
155/// * Connection errors
156/// * Dropped connections
157/// * Timeouts for [safe] / read-only requests
158///
159/// Requests will be retried up to some limit, using exponential
160/// backoff with jitter. See `BackoffConfig` for more information.
161///
162/// ## Default values
163///
164/// | Field           | Default   | Guidance                                                  |
165/// |-----------------|-----------|-----------------------------------------------------------|
166/// | `max_retries`   | `10`      | Reduce to `3`–`5` for latency-sensitive interactive paths |
167/// | `retry_timeout` | `180 s`   | Keep below **5 minutes** so credentials stay valid across all retry attempts |
168/// | `backoff`       | see `BackoffConfig` | Exponential with jitter; tune `base` / `deadline` for your SLA |
169///
170/// [safe]: https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
171#[derive(Debug, Clone)]
172pub struct RetryConfig {
173    /// The backoff configuration
174    pub backoff: BackoffConfig,
175
176    /// The maximum number of times to retry a request
177    ///
178    /// Set to 0 to disable retries
179    pub max_retries: usize,
180
181    /// The maximum length of time from the initial request
182    /// after which no further retries will be attempted
183    ///
184    /// This not only bounds the length of time before a server
185    /// error will be surfaced to the application, but also bounds
186    /// the length of time a request's credentials must remain valid.
187    ///
188    /// As requests are retried without renewing credentials or
189    /// regenerating request payloads, this number should be kept
190    /// below 5 minutes to avoid errors due to expired credentials
191    /// and/or request payloads
192    pub retry_timeout: Duration,
193}
194
195impl Default for RetryConfig {
196    fn default() -> Self {
197        Self {
198            backoff: Default::default(),
199            max_retries: 10,
200            retry_timeout: Duration::from_secs(3 * 60),
201        }
202    }
203}
204
205pub(crate) struct RetryableRequest {
206    service: Arc<dyn HttpService>,
207    request: Request,
208
209    max_retries: usize,
210    retry_timeout: Duration,
211    backoff: Backoff,
212
213    sensitive: bool,
214    idempotent: Option<bool>,
215}
216
217impl RetryableRequest {
218    /// Set whether this request is idempotent
219    ///
220    /// An idempotent request will be retried on timeout even if the request
221    /// method is not [safe](https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1)
222    pub(crate) fn idempotent(self, idempotent: bool) -> Self {
223        Self {
224            idempotent: Some(idempotent),
225            ..self
226        }
227    }
228
229    /// Set whether this request contains sensitive data
230    ///
231    /// This will avoid printing out the URL in error messages
232    #[allow(unused)]
233    pub(crate) fn sensitive(self, sensitive: bool) -> Self {
234        Self { sensitive, ..self }
235    }
236
237    pub(crate) async fn send(self) -> Result<Response> {
238        let max_retries = self.max_retries;
239        let retry_timeout = self.retry_timeout;
240        let mut retries = 0;
241        let now = Instant::now();
242
243        let mut backoff = self.backoff;
244        let is_idempotent = self
245            .idempotent
246            .unwrap_or_else(|| self.request.method().is_safe());
247
248        let sanitize_err = move |e: reqwest::Error| match self.sensitive {
249            true => e.without_url(),
250            false => e,
251        };
252
253        loop {
254            let request = self
255                .request
256                .try_clone()
257                .expect("request body must be cloneable");
258
259            match self.service.call(request).await {
260                Ok(r) => match r.error_for_status_ref() {
261                    Ok(_) if r.status().is_success() => {
262                        return Ok(r);
263                    }
264                    Ok(r) if r.status() == StatusCode::NOT_MODIFIED => {
265                        return Err(Error::Client {
266                            body: None,
267                            status: StatusCode::NOT_MODIFIED,
268                        });
269                    }
270                    Ok(r) => {
271                        let is_bare_redirect =
272                            r.status().is_redirection() && !r.headers().contains_key(LOCATION);
273                        return match is_bare_redirect {
274                            true => Err(Error::BareRedirect),
275                            // Not actually sure if this is reachable, but here for completeness
276                            false => Err(Error::Client {
277                                body: None,
278                                status: r.status(),
279                            }),
280                        };
281                    }
282                    Err(e) => {
283                        let e = sanitize_err(e);
284                        let status = r.status();
285                        if retries == max_retries
286                            || now.elapsed() > retry_timeout
287                            || !status.is_server_error()
288                        {
289                            return Err(match status.is_client_error() {
290                                true => match r.text().await {
291                                    Ok(body) => Error::Client {
292                                        body: Some(body).filter(|b| !b.is_empty()),
293                                        status,
294                                    },
295                                    Err(e) => Error::Reqwest {
296                                        retries,
297                                        max_retries,
298                                        elapsed: now.elapsed(),
299                                        retry_timeout,
300                                        source: e,
301                                    },
302                                },
303                                false => Error::Reqwest {
304                                    retries,
305                                    max_retries,
306                                    elapsed: now.elapsed(),
307                                    retry_timeout,
308                                    source: e,
309                                },
310                            });
311                        }
312
313                        let sleep = backoff.next();
314                        retries += 1;
315                        info!(
316                            "Encountered server error, backing off for {} seconds, retry {} of {}: {}",
317                            sleep.as_secs_f32(),
318                            retries,
319                            max_retries,
320                            e,
321                        );
322                        tokio::time::sleep(sleep).await;
323                    }
324                },
325                Err(crate::Error::ReqwestError(e)) => {
326                    let e = sanitize_err(e);
327
328                    let mut do_retry = false;
329                    if e.is_connect()
330                        || e.is_body()
331                        || (e.is_request() && !e.is_timeout())
332                        || (is_idempotent && e.is_timeout())
333                    {
334                        do_retry = true
335                    } else {
336                        let mut source = e.source();
337                        while let Some(e) = source {
338                            if let Some(e) = e.downcast_ref::<hyper::Error>() {
339                                do_retry = e.is_closed()
340                                    || e.is_incomplete_message()
341                                    || e.is_body_write_aborted()
342                                    || (is_idempotent && e.is_timeout());
343                                break;
344                            }
345                            if let Some(e) = e.downcast_ref::<std::io::Error>() {
346                                if e.kind() == std::io::ErrorKind::TimedOut {
347                                    do_retry = is_idempotent;
348                                } else {
349                                    do_retry = matches!(
350                                        e.kind(),
351                                        std::io::ErrorKind::ConnectionReset
352                                            | std::io::ErrorKind::ConnectionAborted
353                                            | std::io::ErrorKind::BrokenPipe
354                                            | std::io::ErrorKind::UnexpectedEof
355                                    );
356                                }
357                                break;
358                            }
359                            source = e.source();
360                        }
361                    }
362
363                    if retries == max_retries || now.elapsed() > retry_timeout || !do_retry {
364                        return Err(Error::Reqwest {
365                            retries,
366                            max_retries,
367                            elapsed: now.elapsed(),
368                            retry_timeout,
369                            source: e,
370                        });
371                    }
372                    let sleep = backoff.next();
373                    retries += 1;
374                    info!(
375                        "Encountered transport error backing off for {} seconds, retry {} of {}: {}",
376                        sleep.as_secs_f32(),
377                        retries,
378                        max_retries,
379                        e,
380                    );
381                    tokio::time::sleep(sleep).await;
382                }
383                // Any non-reqwest error surfaced by the service (e.g. a cancelled
384                // I/O task during runtime shutdown) is a non-retryable transport
385                // failure. Return it immediately without consuming a retry.
386                Err(other) => {
387                    return Err(Error::Transport {
388                        source: Box::new(other),
389                    });
390                }
391            }
392        }
393    }
394}
395
396pub(crate) trait RetryExt {
397    /// Return a [`RetryableRequest`]
398    fn retryable(self, config: &RetryConfig, service: Arc<dyn HttpService>) -> RetryableRequest;
399
400    /// Dispatch a request with the given retry configuration
401    ///
402    /// # Panic
403    ///
404    /// This will panic if the request body is a stream
405    fn send_retry(
406        self,
407        config: &RetryConfig,
408        service: Arc<dyn HttpService>,
409    ) -> BoxFuture<'static, Result<Response>>;
410}
411
412impl RetryExt for reqwest::RequestBuilder {
413    fn retryable(self, config: &RetryConfig, service: Arc<dyn HttpService>) -> RetryableRequest {
414        let (_client, request) = self.build_split();
415        let request = request.expect("request must be valid");
416
417        RetryableRequest {
418            service,
419            request,
420            max_retries: config.max_retries,
421            retry_timeout: config.retry_timeout,
422            backoff: Backoff::new(&config.backoff),
423            sensitive: false,
424            idempotent: None,
425        }
426    }
427
428    fn send_retry(
429        self,
430        config: &RetryConfig,
431        service: Arc<dyn HttpService>,
432    ) -> BoxFuture<'static, Result<Response>> {
433        let request = self.retryable(config, service);
434        Box::pin(async move { request.send().await })
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::RetryConfig;
441    use crate::retry::{Error, RetryExt};
442    use crate::service::ReqwestService;
443
444    use reqwest::{Client, Method, StatusCode};
445    use std::sync::Arc;
446    use std::time::Duration;
447
448    #[tokio::test]
449    async fn test_retry() {
450        let mut server = mockito::Server::new_async().await;
451
452        let retry = RetryConfig {
453            backoff: Default::default(),
454            max_retries: 2,
455            retry_timeout: Duration::from_secs(1000),
456        };
457
458        let client = Client::builder()
459            .timeout(Duration::from_millis(100))
460            .build()
461            .unwrap();
462
463        let service = Arc::new(ReqwestService::new(client.clone()));
464        let server_url = server.url();
465        let do_request = || {
466            client
467                .request(Method::GET, &server_url)
468                .send_retry(&retry, service.clone())
469        };
470
471        // Simple request should work
472        let _mock1 = server
473            .mock("GET", "/")
474            .with_status(200)
475            .with_body("")
476            .create_async()
477            .await;
478
479        let r = do_request().await.unwrap();
480        assert_eq!(r.status(), StatusCode::OK);
481
482        // Returns client errors immediately with status message
483        let _mock2 = server
484            .mock("GET", "/")
485            .with_status(400)
486            .with_body("cupcakes")
487            .create_async()
488            .await;
489
490        let e = do_request().await.unwrap_err();
491        assert_eq!(e.status().unwrap(), StatusCode::BAD_REQUEST);
492        assert_eq!(e.body(), Some("cupcakes"));
493        assert_eq!(
494            e.to_string(),
495            "Client error with status 400 Bad Request: cupcakes"
496        );
497
498        // Handles client errors with no payload
499        let _mock3 = server
500            .mock("GET", "/")
501            .with_status(400)
502            .with_body("")
503            .create_async()
504            .await;
505
506        let e = do_request().await.unwrap_err();
507        assert_eq!(e.status().unwrap(), StatusCode::BAD_REQUEST);
508        assert_eq!(e.body(), None);
509        assert_eq!(
510            e.to_string(),
511            "Client error with status 400 Bad Request: No Body"
512        );
513
514        // Should retry server error request
515        let _mock4 = server
516            .mock("GET", "/")
517            .with_status(502)
518            .with_body("")
519            .create_async()
520            .await;
521
522        let _mock5 = server
523            .mock("GET", "/")
524            .with_status(200)
525            .with_body("")
526            .create_async()
527            .await;
528
529        let r = do_request().await.unwrap();
530        assert_eq!(r.status(), StatusCode::OK);
531
532        // Accepts 204 status code
533        let _mock6 = server
534            .mock("GET", "/")
535            .with_status(204)
536            .with_body("")
537            .create_async()
538            .await;
539
540        let r = do_request().await.unwrap();
541        assert_eq!(r.status(), StatusCode::NO_CONTENT);
542
543        // Follows 302 redirects
544        let _mock7 = server
545            .mock("GET", "/")
546            .with_status(302)
547            .with_header("location", "/foo")
548            .with_body("")
549            .create_async()
550            .await;
551
552        let _mock8 = server
553            .mock("GET", "/foo")
554            .with_status(200)
555            .with_body("")
556            .create_async()
557            .await;
558
559        let r = do_request().await.unwrap();
560        assert_eq!(r.status(), StatusCode::OK);
561        assert_eq!(r.url().path(), "/foo");
562
563        // Handles redirect missing location
564        let _mock_redirect = server
565            .mock("GET", "/")
566            .with_status(302)
567            .with_body("")
568            .create_async()
569            .await;
570
571        let e = do_request().await.unwrap_err();
572        assert!(matches!(e, Error::BareRedirect));
573        assert_eq!(
574            e.to_string(),
575            "Received redirect without LOCATION, this normally indicates an incorrectly configured region"
576        );
577
578        // Test timeout scenarios with delays
579        let _timeout_mock1 = server
580            .mock("GET", "/")
581            .with_status(200)
582            .with_body("")
583            .create_async()
584            .await;
585
586        let _timeout_mock2 = server
587            .mock("GET", "/")
588            .with_status(200)
589            .with_body("")
590            .create_async()
591            .await;
592
593        do_request().await.unwrap();
594
595        // Test sensitive URL handling
596        let sensitive_url = format!("{server_url}/SENSITIVE");
597        let _sensitive_mock = server
598            .mock("GET", "/SENSITIVE")
599            .with_status(502)
600            .with_body("ignored")
601            .create_async()
602            .await;
603
604        let res = client
605            .request(Method::GET, &sensitive_url)
606            .send_retry(&retry, service.clone())
607            .await;
608        let err = res.unwrap_err().to_string();
609        assert!(err.contains("SENSITIVE"), "{err}");
610
611        // Test sensitive request with retryable that strips URL
612        let _sensitive_mock2 = server
613            .mock("GET", "/SENSITIVE")
614            .with_status(502)
615            .with_body("ignored")
616            .create_async()
617            .await;
618
619        let req = client
620            .request(Method::GET, &sensitive_url)
621            .retryable(&retry, service.clone())
622            .sensitive(true);
623        let err = req.send().await.unwrap_err().to_string();
624        assert!(!err.contains("SENSITIVE"), "{err}");
625    }
626
627    #[tokio::test]
628    async fn test_retry_exhaustion_returns_last_server_error() {
629        let mut server = mockito::Server::new_async().await;
630
631        let retry = RetryConfig {
632            // Keep backoff tiny so the test is fast.
633            backoff: crate::backoff::BackoffConfig {
634                init_backoff: Duration::from_millis(1),
635                max_backoff: Duration::from_millis(2),
636                base: 2.,
637            },
638            max_retries: 2,
639            retry_timeout: Duration::from_secs(1000),
640        };
641
642        let client = Client::builder()
643            .timeout(Duration::from_millis(100))
644            .build()
645            .unwrap();
646        let service = Arc::new(ReqwestService::new(client.clone()));
647        let server_url = server.url();
648
649        // The server always returns 502. With max_retries = 2 that is three
650        // attempts total, all failing, after which the last error is surfaced.
651        let mock = server
652            .mock("GET", "/")
653            .with_status(502)
654            .with_body("upstream boom")
655            .expect(3)
656            .create_async()
657            .await;
658
659        let err = client
660            .request(Method::GET, &server_url)
661            .send_retry(&retry, service.clone())
662            .await
663            .unwrap_err();
664
665        // 5xx that exhausts retries surfaces as a Reqwest error carrying the
666        // final 502 status.
667        assert!(matches!(err, Error::Reqwest { .. }), "{err:?}");
668        assert_eq!(err.status(), Some(StatusCode::BAD_GATEWAY));
669        mock.assert_async().await;
670    }
671
672    #[tokio::test]
673    async fn test_no_retries_returns_immediately() {
674        let mut server = mockito::Server::new_async().await;
675
676        let retry = RetryConfig {
677            backoff: Default::default(),
678            max_retries: 0,
679            retry_timeout: Duration::from_secs(1000),
680        };
681
682        let client = Client::builder().build().unwrap();
683        let service = Arc::new(ReqwestService::new(client.clone()));
684        let server_url = server.url();
685
686        // With max_retries = 0 the server is hit exactly once even on a 5xx.
687        let mock = server
688            .mock("GET", "/")
689            .with_status(503)
690            .with_body("")
691            .expect(1)
692            .create_async()
693            .await;
694
695        let err = client
696            .request(Method::GET, &server_url)
697            .send_retry(&retry, service.clone())
698            .await
699            .unwrap_err();
700
701        assert_eq!(err.status(), Some(StatusCode::SERVICE_UNAVAILABLE));
702        mock.assert_async().await;
703    }
704}