Skip to main content

jev_sdk/
retry.rs

1//! Retry behavior for transient failures.
2
3use reqwest::header::HeaderMap;
4use reqwest::StatusCode;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7/// Controls how failed requests are retried.
8///
9/// By default the SDK retries up to 2 times on connection errors and on the
10/// statuses listed in [`RetryPolicy::retryable_statuses`] (which includes the
11/// documented `429 Too Many Requests` and `529 Overloaded`), using exponential
12/// backoff with jitter, and honoring the `Retry-After` response header.
13///
14/// Set [`RetryPolicy::none`] (or `max_retries: 0`) to disable retries.
15#[derive(Debug, Clone)]
16pub struct RetryPolicy {
17    /// Maximum number of retries after the initial attempt. `0` disables
18    /// retries.
19    pub max_retries: u32,
20    /// Backoff before the first retry.
21    pub initial_backoff: Duration,
22    /// Upper bound for any single backoff.
23    pub max_backoff: Duration,
24    /// Multiplier applied to the backoff after each attempt.
25    pub multiplier: f64,
26    /// HTTP statuses considered transient.
27    pub retryable_statuses: Vec<u16>,
28}
29
30impl Default for RetryPolicy {
31    fn default() -> Self {
32        Self {
33            max_retries: 2,
34            initial_backoff: Duration::from_millis(500),
35            max_backoff: Duration::from_secs(8),
36            multiplier: 2.0,
37            retryable_statuses: vec![408, 409, 425, 429, 500, 502, 503, 504, 529],
38        }
39    }
40}
41
42impl RetryPolicy {
43    /// A policy that never retries.
44    pub fn none() -> Self {
45        Self {
46            max_retries: 0,
47            ..Self::default()
48        }
49    }
50
51    /// Whether a response status is retryable under this policy.
52    pub fn should_retry_status(&self, status: StatusCode) -> bool {
53        self.retryable_statuses.contains(&status.as_u16())
54    }
55
56    /// The delay before retry attempt `attempt` (0 = first retry), including
57    /// up to 25% random jitter and capped at [`RetryPolicy::max_backoff`].
58    ///
59    /// This function is total: non-finite or non-positive multipliers fall
60    /// back to `2.0`, and overflow saturates at `max_backoff` instead of
61    /// panicking.
62    pub fn backoff_for(&self, attempt: u32) -> Duration {
63        let multiplier = if self.multiplier.is_finite() && self.multiplier > 0.0 {
64            self.multiplier
65        } else {
66            2.0
67        };
68        // Cheap jitter derived from the wall clock; avoids a `rand` dependency.
69        let nanos = SystemTime::now()
70            .duration_since(UNIX_EPOCH)
71            .map(|d| d.subsec_nanos())
72            .unwrap_or(0);
73        let jitter = 1.0 + (nanos % 250) as f64 / 1000.0; // 1.00 ..= 1.249
74
75        // Cap the exponent so huge `attempt` values overflow fast into the
76        // saturation branch below instead of producing inf * 0 edge cases.
77        let secs =
78            self.initial_backoff.as_secs_f64() * multiplier.powi(attempt.min(64) as i32) * jitter;
79
80        let max_secs = self.max_backoff.as_secs_f64();
81        if !secs.is_finite() || secs < 0.0 || secs >= max_secs {
82            // `max_backoff` is a Duration already, so this never converts a
83            // huge f64 back into a Duration.
84            return self.max_backoff;
85        }
86        Duration::from_secs_f64(secs)
87    }
88
89    /// The delay to use for a failed response: the larger of the computed
90    /// backoff and the server's `Retry-After` hint. The hint is capped at
91    /// [`MAX_RETRY_AFTER`] so a hostile or buggy server cannot make the
92    /// client sleep indefinitely.
93    pub(crate) fn delay_for(&self, attempt: u32, headers: &HeaderMap) -> Duration {
94        let backoff = self.backoff_for(attempt);
95        match parse_retry_after(headers) {
96            Some(hint) => backoff.max(hint.min(MAX_RETRY_AFTER)),
97            None => backoff,
98        }
99    }
100}
101
102/// Upper bound for a server-provided `Retry-After` hint.
103pub(crate) const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
104
105/// Parse a `Retry-After` header expressed in (fractional) seconds.
106fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
107    let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
108    let secs: f64 = value.trim().parse().ok()?;
109    (secs.is_finite() && secs >= 0.0).then(|| Duration::from_secs_f64(secs))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use reqwest::header::HeaderValue;
116
117    #[test]
118    fn defaults_retry_documented_statuses() {
119        let p = RetryPolicy::default();
120        assert!(p.should_retry_status(StatusCode::TOO_MANY_REQUESTS));
121        assert!(p.should_retry_status(StatusCode::from_u16(529).unwrap()));
122        assert!(!p.should_retry_status(StatusCode::BAD_REQUEST));
123        assert!(!p.should_retry_status(StatusCode::UNAUTHORIZED));
124    }
125
126    #[test]
127    fn backoff_grows_and_is_capped() {
128        let p = RetryPolicy::default();
129        let b0 = p.backoff_for(0);
130        let b1 = p.backoff_for(1);
131        assert!(b0 >= Duration::from_millis(500));
132        assert!(b1 >= b0);
133        let huge = p.backoff_for(30);
134        assert!(huge <= Duration::from_secs(8));
135    }
136
137    #[test]
138    fn backoff_never_panics_on_extreme_inputs() {
139        let mut p = RetryPolicy::default();
140
141        // Absurd attempt counts saturate instead of overflowing to infinity.
142        assert_eq!(p.backoff_for(u32::MAX), Duration::from_secs(8));
143
144        // Non-finite / non-positive multipliers fall back to 2.0.
145        p.multiplier = f64::NAN;
146        assert!(p.backoff_for(5) <= Duration::from_secs(8));
147        p.multiplier = f64::INFINITY;
148        assert!(p.backoff_for(5) <= Duration::from_secs(8));
149        p.multiplier = -3.0;
150        assert!(p.backoff_for(5) <= Duration::from_secs(8));
151
152        // A zero cap yields zero delay.
153        p.max_backoff = Duration::ZERO;
154        assert_eq!(p.backoff_for(0), Duration::ZERO);
155    }
156
157    #[test]
158    fn retry_after_hint_is_capped() {
159        let p = RetryPolicy::default();
160        let mut headers = HeaderMap::new();
161        // A hostile server hint of ~31 years must not be honored.
162        headers.insert(
163            reqwest::header::RETRY_AFTER,
164            HeaderValue::from_static("999999999"),
165        );
166        let delay = p.delay_for(0, &headers);
167        assert!(
168            delay <= MAX_RETRY_AFTER,
169            "delay {delay:?} exceeded the cap {MAX_RETRY_AFTER:?}"
170        );
171    }
172
173    #[test]
174    fn parses_retry_after_seconds() {
175        let mut headers = HeaderMap::new();
176        headers.insert(reqwest::header::RETRY_AFTER, HeaderValue::from_static("2"));
177        assert_eq!(parse_retry_after(&headers), Some(Duration::from_secs(2)));
178
179        headers.insert(
180            reqwest::header::RETRY_AFTER,
181            HeaderValue::from_static("0.25"),
182        );
183        assert_eq!(
184            parse_retry_after(&headers),
185            Some(Duration::from_millis(250))
186        );
187
188        headers.insert(
189            reqwest::header::RETRY_AFTER,
190            HeaderValue::from_static("not-a-number"),
191        );
192        assert_eq!(parse_retry_after(&headers), None);
193    }
194}