Skip to main content

s3_wire/retry/
mod.rs

1//! Retry policy and classification.
2
3use std::time::Duration;
4
5use crate::error::{RetryClassification, S3Error};
6
7/// Bounded exponential-backoff policy with full jitter.
8#[derive(Clone, Debug, Eq, PartialEq)]
9pub struct RetryPolicy {
10    max_attempts: u32,
11    base_delay: Duration,
12    max_delay: Duration,
13    max_elapsed: Duration,
14}
15
16impl RetryPolicy {
17    /// Creates a policy with production-oriented defaults.
18    pub fn standard() -> Self {
19        Self {
20            max_attempts: 4,
21            base_delay: Duration::from_millis(100),
22            max_delay: Duration::from_secs(10),
23            max_elapsed: Duration::from_secs(30),
24        }
25    }
26
27    /// Creates and validates a retry policy.
28    pub fn new(
29        max_attempts: u32,
30        base_delay: Duration,
31        max_delay: Duration,
32        max_elapsed: Duration,
33    ) -> Result<Self, S3Error> {
34        if max_attempts == 0 {
35            return Err(S3Error::configuration(
36                "retry max_attempts must be at least one",
37            ));
38        }
39        if base_delay.is_zero() {
40            return Err(S3Error::configuration(
41                "retry base_delay must be greater than zero",
42            ));
43        }
44        if max_delay < base_delay {
45            return Err(S3Error::configuration(
46                "retry max_delay must not be shorter than base_delay",
47            ));
48        }
49        if max_elapsed.is_zero() {
50            return Err(S3Error::configuration(
51                "retry max_elapsed must be greater than zero",
52            ));
53        }
54        Ok(Self {
55            max_attempts,
56            base_delay,
57            max_delay,
58            max_elapsed,
59        })
60    }
61
62    /// Returns the total number of allowed attempts, including the initial attempt.
63    pub fn max_attempts(&self) -> u32 {
64        self.max_attempts
65    }
66
67    /// Returns the initial backoff delay.
68    pub fn base_delay(&self) -> Duration {
69        self.base_delay
70    }
71
72    /// Returns the maximum delay between attempts.
73    pub fn max_delay(&self) -> Duration {
74        self.max_delay
75    }
76
77    /// Returns the maximum elapsed time in which another attempt may begin.
78    pub fn max_elapsed(&self) -> Duration {
79        self.max_elapsed
80    }
81
82    /// Returns whether another attempt is allowed by classification and bounds.
83    pub fn permits_retry(
84        &self,
85        classification: RetryClassification,
86        attempts_completed: u32,
87        elapsed: Duration,
88    ) -> bool {
89        classification != RetryClassification::Never
90            && attempts_completed < self.max_attempts
91            && elapsed < self.max_elapsed
92    }
93
94    /// Computes a full-jitter delay after `attempts_completed` attempts.
95    ///
96    /// A server-provided `Retry-After` value is honored exactly when it fits
97    /// within the remaining elapsed-time bound.
98    pub fn delay(
99        &self,
100        attempts_completed: u32,
101        elapsed: Duration,
102        retry_after: Option<Duration>,
103    ) -> Option<Duration> {
104        if attempts_completed == 0
105            || attempts_completed >= self.max_attempts
106            || elapsed >= self.max_elapsed
107        {
108            return None;
109        }
110
111        let remaining = self.max_elapsed.saturating_sub(elapsed);
112        if let Some(delay) = retry_after {
113            return (delay <= remaining).then_some(delay);
114        }
115
116        let exponent = attempts_completed.saturating_sub(1).min(31);
117        let multiplier = 1_u32 << exponent;
118        let ceiling = self.base_delay.saturating_mul(multiplier);
119        let ceiling = ceiling.min(self.max_delay).min(remaining);
120        let ceiling_nanos = u64::try_from(ceiling.as_nanos()).unwrap_or(u64::MAX);
121        let nanos = fastrand::u64(0..=ceiling_nanos);
122        Some(Duration::from_nanos(nanos))
123    }
124}
125
126impl Default for RetryPolicy {
127    fn default() -> Self {
128        Self::standard()
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn policy_rejects_invalid_bounds() {
138        assert!(
139            RetryPolicy::new(
140                0,
141                Duration::from_millis(1),
142                Duration::from_secs(1),
143                Duration::from_secs(2)
144            )
145            .is_err()
146        );
147        assert!(
148            RetryPolicy::new(
149                2,
150                Duration::from_secs(2),
151                Duration::from_secs(1),
152                Duration::from_secs(3)
153            )
154            .is_err()
155        );
156    }
157
158    #[test]
159    fn delay_is_bounded_by_policy_and_elapsed_time() {
160        let policy = RetryPolicy::new(
161            10,
162            Duration::from_secs(1),
163            Duration::from_secs(8),
164            Duration::from_secs(10),
165        )
166        .unwrap();
167
168        assert_eq!(
169            policy.delay(1, Duration::from_secs(1), Some(Duration::from_secs(9))),
170            Some(Duration::from_secs(9))
171        );
172        assert!(
173            policy
174                .delay(9, Duration::from_secs(9), Some(Duration::from_secs(30)))
175                .is_none()
176        );
177        assert!(policy.delay(10, Duration::from_secs(9), None).is_none());
178    }
179}