Skip to main content

qcs_api_client_common/
backoff.rs

1//! Exponential backoff for use with QCS.
2//!
3//! This re-exports types from [`backon`](::backon) and provides a [`default_backoff`] function
4//! to create a more useful default [`ExponentialBuilder`].
5//!
6//! [`ExponentialBuilder`] is cheaply `Clone`/`Copy`, so it can be stored and reused to
7//! [`BackoffBuilder::build`] a fresh [`ExponentialBackoff`] iterator for each retry sequence.
8
9use std::{error::Error as _, time::Duration};
10
11use qcs_dependencies_client::http::StatusCode;
12
13pub use ::backon::*;
14
15/// Create a default [`ExponentialBuilder`] for use with QCS.
16///
17/// The built backoff will retry for up to 5 minutes, with a maximum interval of 30 seconds and
18/// some randomized jitter.
19#[allow(clippy::module_name_repetitions)]
20#[must_use]
21pub const fn default_backoff() -> ExponentialBuilder {
22    ExponentialBuilder::new()
23        .with_jitter()
24        .with_min_delay(Duration::from_millis(500))
25        .with_factor(1.5)
26        .with_max_delay(Duration::from_secs(30))
27        .with_total_delay(Some(Duration::from_secs(300)))
28        .without_max_times()
29}
30
31/// Return `true` if the status code is one that could be retried.
32#[must_use]
33pub const fn status_code_is_retry(code: StatusCode) -> bool {
34    matches!(
35        code,
36        StatusCode::SERVICE_UNAVAILABLE | StatusCode::BAD_GATEWAY | StatusCode::TOO_MANY_REQUESTS
37    )
38}
39
40/// Return `Some` if the response specifies a `Retry-After` header or the provided `backoff` has
41/// another backoff to try. If `None` is returned, the request should not be retried.
42#[must_use]
43pub fn duration_from_response(
44    status: StatusCode,
45    headers: &qcs_dependencies_client::http::HeaderMap,
46    backoff: &mut ExponentialBackoff,
47) -> Option<Duration> {
48    use time::{OffsetDateTime, format_description::well_known::Rfc2822};
49
50    if status_code_is_retry(status) {
51        if let Some(value) = headers.get(qcs_dependencies_client::http::header::RETRY_AFTER)
52            && let Ok(value) = value.to_str()
53        {
54            if let Ok(value) = value.parse::<u64>() {
55                return Some(Duration::from_secs(value));
56            } else if let Ok(date) = OffsetDateTime::parse(value, &Rfc2822) {
57                let duration = date - OffsetDateTime::now_utc();
58                // Convert from time::Duration to std::time::Duration
59                // This will fail if the number is too large or negative
60                let std_duration: Duration = duration.try_into().ok()?;
61                return Some(std_duration);
62            }
63        }
64
65        backoff.next()
66    } else {
67        None
68    }
69}
70
71fn can_retry_method(method: &qcs_dependencies_client::http::Method) -> bool {
72    // Safe means the method is essentially read-only (see https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1)
73    // Idempotent means multiple identical requests have the same side-effects as a single one (see https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.2)
74
75    // Idempotent methods are defined as safe methods + PUT and DELETE.
76    // Since we have some API endpoints using PUT and DELETE that are not idempotent, this function
77    // currently returns just safe methods.
78
79    method.is_safe()
80}
81
82/// Return `Some` if the error is one that makes sense to retry and `method` is one that indicates
83/// it is safe to retry.
84#[must_use]
85pub fn duration_from_reqwest_error(
86    method: &qcs_dependencies_client::http::Method,
87    error: &qcs_dependencies_client::reqwest::Error,
88    backoff: &mut ExponentialBackoff,
89) -> Option<Duration> {
90    if can_retry_method(method) {
91        if error.is_timeout()
92            || error.is_connect()
93            || error.is_request()
94            || error
95                .source()
96                .and_then(|inner| inner.downcast_ref::<hyper::Error>())
97                .is_some_and(hyper::Error::is_closed)
98        {
99            backoff.next()
100        } else {
101            None
102        }
103    } else {
104        None
105    }
106}
107
108/// Return `Some` if the error is one that makes sense to retry and `method` is one that indicates
109/// it is safe to retry.
110#[must_use]
111pub fn duration_from_io_error(
112    method: &qcs_dependencies_client::http::Method,
113    error: &std::io::Error,
114    backoff: &mut ExponentialBackoff,
115) -> Option<Duration> {
116    use std::io::ErrorKind;
117    if can_retry_method(method) {
118        if matches!(
119            error.kind(),
120            ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted
121        ) {
122            backoff.next()
123        } else {
124            None
125        }
126    } else {
127        None
128    }
129}