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        self.delay_for(
105            RetryClassification::Retryable,
106            attempts_completed,
107            elapsed,
108            retry_after,
109        )
110    }
111
112    /// Computes a classification-aware full-jitter delay.
113    ///
114    /// Standard AWS retry behavior uses a one-second backoff base for
115    /// throttling and a shorter base for other transient failures.
116    pub fn delay_for(
117        &self,
118        classification: RetryClassification,
119        attempts_completed: u32,
120        elapsed: Duration,
121        retry_after: Option<Duration>,
122    ) -> Option<Duration> {
123        if attempts_completed == 0
124            || attempts_completed >= self.max_attempts
125            || elapsed >= self.max_elapsed
126            || classification == RetryClassification::Never
127        {
128            return None;
129        }
130
131        let remaining = self.max_elapsed.saturating_sub(elapsed);
132        if let Some(delay) = retry_after {
133            return (delay <= remaining).then_some(delay);
134        }
135
136        let ceiling = self
137            .delay_ceiling(classification, attempts_completed)
138            .min(remaining);
139        let ceiling_nanos = u64::try_from(ceiling.as_nanos()).unwrap_or(u64::MAX);
140        let nanos = fastrand::u64(0..=ceiling_nanos);
141        Some(Duration::from_nanos(nanos))
142    }
143
144    fn delay_ceiling(
145        &self,
146        classification: RetryClassification,
147        attempts_completed: u32,
148    ) -> Duration {
149        let base = if classification == RetryClassification::Throttled {
150            self.base_delay.max(Duration::from_secs(1))
151        } else {
152            self.base_delay
153        };
154        let exponent = attempts_completed.saturating_sub(1).min(31);
155        let multiplier = 1_u32 << exponent;
156        base.saturating_mul(multiplier).min(self.max_delay)
157    }
158}
159
160impl Default for RetryPolicy {
161    fn default() -> Self {
162        Self::standard()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn policy_rejects_invalid_bounds() {
172        assert!(
173            RetryPolicy::new(
174                0,
175                Duration::from_millis(1),
176                Duration::from_secs(1),
177                Duration::from_secs(2)
178            )
179            .is_err()
180        );
181        assert!(
182            RetryPolicy::new(
183                2,
184                Duration::from_secs(2),
185                Duration::from_secs(1),
186                Duration::from_secs(3)
187            )
188            .is_err()
189        );
190    }
191
192    #[test]
193    fn delay_is_bounded_by_policy_and_elapsed_time() {
194        let policy = RetryPolicy::new(
195            10,
196            Duration::from_secs(1),
197            Duration::from_secs(8),
198            Duration::from_secs(10),
199        )
200        .unwrap();
201
202        assert_eq!(
203            policy.delay(1, Duration::from_secs(1), Some(Duration::from_secs(9))),
204            Some(Duration::from_secs(9))
205        );
206        assert!(
207            policy
208                .delay(9, Duration::from_secs(9), Some(Duration::from_secs(30)))
209                .is_none()
210        );
211        assert!(policy.delay(10, Duration::from_secs(9), None).is_none());
212    }
213
214    #[test]
215    fn throttling_uses_the_standard_longer_backoff_base() {
216        let policy = RetryPolicy::new(
217            4,
218            Duration::from_millis(100),
219            Duration::from_secs(10),
220            Duration::from_secs(30),
221        )
222        .unwrap();
223
224        assert_eq!(
225            policy.delay_ceiling(RetryClassification::Retryable, 1),
226            Duration::from_millis(100)
227        );
228        assert_eq!(
229            policy.delay_ceiling(RetryClassification::Throttled, 1),
230            Duration::from_secs(1)
231        );
232    }
233}