Skip to main content

typesafe_systemone/
retry.rs

1use std::time::{Duration, SystemTime, UNIX_EPOCH};
2
3/// Retry behaviour for transient failures.
4///
5/// Retried: HTTP 408, 429, every 5xx (including 529 Overloaded), connection errors and
6/// timeouts. Not retried: 4xx other than 408/429. Defaults mirror TypeSafe's official SDKs.
7#[derive(Clone, Debug, PartialEq)]
8pub struct RetryPolicy {
9    /// Retries after the first attempt. `0` disables retrying.
10    pub max_retries: u32,
11    /// First backoff delay; doubled on each retry up to `backoff_max`.
12    pub backoff_initial: Duration,
13    /// Upper bound for a backoff delay.
14    pub backoff_max: Duration,
15    /// Fraction of each delay randomly subtracted, in `0.0..=1.0`.
16    pub backoff_jitter: f64,
17    /// Honour a `retry-after` header when the server sends one.
18    pub respect_retry_after: bool,
19}
20
21impl Default for RetryPolicy {
22    fn default() -> Self {
23        Self {
24            max_retries: 2,
25            backoff_initial: Duration::from_millis(500),
26            backoff_max: Duration::from_secs(5),
27            backoff_jitter: 0.25,
28            respect_retry_after: true,
29        }
30    }
31}
32
33impl RetryPolicy {
34    /// Never retry.
35    pub fn none() -> Self {
36        Self {
37            max_retries: 0,
38            ..Self::default()
39        }
40    }
41
42    pub(crate) fn is_retryable_status(status: u16) -> bool {
43        status == 408 || status == 429 || (500..=599).contains(&status)
44    }
45
46    /// Delay before retry number `retry` (1-based). `retry_after` wins when honoured.
47    pub(crate) fn delay(&self, retry: u32, retry_after: Option<Duration>) -> Duration {
48        if self.respect_retry_after {
49            if let Some(d) = retry_after {
50                return d.min(self.backoff_max.max(d));
51            }
52        }
53        let exp = self
54            .backoff_initial
55            .saturating_mul(2u32.saturating_pow(retry.saturating_sub(1)));
56        let base = exp.min(self.backoff_max);
57        let jitter = self.backoff_jitter.clamp(0.0, 1.0);
58        if jitter == 0.0 {
59            return base;
60        }
61        base.mul_f64(1.0 - jitter * unit_random())
62    }
63}
64
65/// Cheap uniform random in `[0, 1)` without a `rand` dependency; jitter needs no quality.
66fn unit_random() -> f64 {
67    let nanos = SystemTime::now()
68        .duration_since(UNIX_EPOCH)
69        .map(|d| d.subsec_nanos() as u64)
70        .unwrap_or(0);
71    let mut x = nanos ^ 0x9E37_79B9_7F4A_7C15;
72    x ^= x << 13;
73    x ^= x >> 7;
74    x ^= x << 17;
75    (x % 10_000) as f64 / 10_000.0
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn retryable_statuses() {
84        for s in [408, 429, 500, 502, 529, 599] {
85            assert!(RetryPolicy::is_retryable_status(s), "{s}");
86        }
87        for s in [200, 400, 401, 403, 404, 422] {
88            assert!(!RetryPolicy::is_retryable_status(s), "{s}");
89        }
90    }
91
92    #[test]
93    fn backoff_doubles_and_caps() {
94        let p = RetryPolicy {
95            backoff_jitter: 0.0,
96            ..RetryPolicy::default()
97        };
98        assert_eq!(p.delay(1, None), Duration::from_millis(500));
99        assert_eq!(p.delay(2, None), Duration::from_millis(1000));
100        assert_eq!(p.delay(3, None), Duration::from_millis(2000));
101        assert_eq!(p.delay(10, None), Duration::from_secs(5));
102    }
103
104    #[test]
105    fn jitter_only_shortens() {
106        let p = RetryPolicy::default();
107        for _ in 0..50 {
108            let d = p.delay(1, None);
109            assert!(
110                d <= Duration::from_millis(500) && d >= Duration::from_millis(375),
111                "{d:?}"
112            );
113        }
114    }
115
116    #[test]
117    fn retry_after_wins_when_respected() {
118        let p = RetryPolicy::default();
119        assert_eq!(p.delay(1, Some(Duration::from_secs(3))), Duration::from_secs(3));
120        let p = RetryPolicy {
121            respect_retry_after: false,
122            backoff_jitter: 0.0,
123            ..RetryPolicy::default()
124        };
125        assert_eq!(p.delay(1, Some(Duration::from_secs(3))), Duration::from_millis(500));
126    }
127}