Skip to main content

typesafe_rs/
retry.rs

1use std::collections::HashSet;
2use std::time::{Duration, SystemTime};
3
4use http::{HeaderMap, StatusCode};
5
6use crate::error::Error;
7
8/// Set of HTTP status codes that should be retried.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct StatusSet {
11    codes: HashSet<u16>,
12}
13
14impl StatusSet {
15    /// Empty set.
16    #[must_use]
17    pub fn new() -> Self {
18        Self {
19            codes: HashSet::new(),
20        }
21    }
22
23    /// Official default: 408, 429, and every 5xx.
24    #[must_use]
25    pub fn retryable() -> Self {
26        let mut codes = HashSet::with_capacity(102);
27        codes.insert(408);
28        codes.insert(429);
29        for status in 500..=599 {
30            codes.insert(status);
31        }
32        Self { codes }
33    }
34
35    /// Build from explicit codes.
36    #[must_use]
37    pub fn from_codes(codes: impl IntoIterator<Item = u16>) -> Self {
38        Self {
39            codes: codes.into_iter().collect(),
40        }
41    }
42
43    /// Insert a status code.
44    pub fn insert(&mut self, code: u16) {
45        self.codes.insert(code);
46    }
47
48    /// Whether `code` is in the set.
49    #[must_use]
50    pub fn contains(&self, code: u16) -> bool {
51        self.codes.contains(&code)
52    }
53
54    /// Whether `status` is in the set.
55    #[must_use]
56    pub fn contains_status(&self, status: StatusCode) -> bool {
57        self.contains(status.as_u16())
58    }
59}
60
61impl Default for StatusSet {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl FromIterator<u16> for StatusSet {
68    fn from_iter<T: IntoIterator<Item = u16>>(iter: T) -> Self {
69        Self::from_codes(iter)
70    }
71}
72
73/// Retry policy matching the official TypeSafe SDKs.
74///
75/// Default: 2 retries, 500 ms initial backoff, 5 s cap, 25% jitter, HTTP 408/429/5xx,
76/// connection errors, timeouts, and `retry-after-ms` / `Retry-After` up to 60 s.
77///
78/// Struct-update from [`RetryPolicy::default`] so private fields stay initialized:
79/// `RetryPolicy { max_retries: 0, ..RetryPolicy::default() }`.
80///
81/// # Examples
82///
83/// ```
84/// use typesafe_rs::RetryPolicy;
85///
86/// let none = RetryPolicy::none();
87/// assert_eq!(none.max_retries, 0);
88///
89/// let conservative = RetryPolicy::conservative();
90/// assert!(!conservative.retry_timeouts);
91/// ```
92#[derive(Clone, Debug, PartialEq)]
93pub struct RetryPolicy {
94    /// Maximum retries after the initial attempt. Default: 2.
95    pub max_retries: u32,
96    /// First backoff delay. Default: 500 ms.
97    pub backoff_initial: Duration,
98    /// Backoff cap. Default: 5 s.
99    pub backoff_max: Duration,
100    /// Fraction of each backoff randomly subtracted, in `[0, 1]`. Default: 0.25.
101    pub backoff_jitter: f64,
102    /// HTTP statuses that are retried. Default: 408, 429, 500–599.
103    pub http_statuses: StatusSet,
104    /// Honor `retry-after-ms` / `Retry-After` up to [`Self::max_retry_after`].
105    pub respect_retry_after: bool,
106    /// Maximum server-requested delay that will be honored. Default: 60 s.
107    pub max_retry_after: Duration,
108    /// Retry connection failures. Default: true.
109    pub retry_connection_errors: bool,
110    /// Retry timeouts. Default: true.
111    pub retry_timeouts: bool,
112    /// When true, only connection errors that happen before bytes are sent are retried.
113    ///
114    /// Set by [`RetryPolicy::conservative`]. Keep the default (`false`) unless you
115    /// want that behaviour; prefer `..RetryPolicy::default()` in struct updates.
116    pub connect_only_before_send: bool,
117}
118
119impl Default for RetryPolicy {
120    fn default() -> Self {
121        Self {
122            max_retries: 2,
123            backoff_initial: Duration::from_millis(500),
124            backoff_max: Duration::from_secs(5),
125            backoff_jitter: 0.25,
126            http_statuses: StatusSet::retryable(),
127            respect_retry_after: true,
128            max_retry_after: Duration::from_secs(60),
129            retry_connection_errors: true,
130            retry_timeouts: true,
131            connect_only_before_send: false,
132        }
133    }
134}
135
136impl RetryPolicy {
137    /// No retries (`max_retries = 0`).
138    #[must_use]
139    pub fn none() -> Self {
140        Self {
141            max_retries: 0,
142            ..Self::default()
143        }
144    }
145
146    /// Retry 408/429 and pre-send connection errors only. No 5xx, no timeouts.
147    #[must_use]
148    pub fn conservative() -> Self {
149        Self {
150            http_statuses: StatusSet::from_codes([408, 429]),
151            retry_timeouts: false,
152            retry_connection_errors: true,
153            connect_only_before_send: true,
154            ..Self::default()
155        }
156    }
157
158    pub(crate) fn validate(&self) -> Result<(), Error> {
159        if !(0.0..=1.0).contains(&self.backoff_jitter) {
160            return Err(Error::InvalidRequest(format!(
161                "`retry.backoff_jitter` must be between 0 and 1, got {}.",
162                self.backoff_jitter
163            )));
164        }
165        Ok(())
166    }
167
168    pub(crate) fn retries_connection(&self, pre_send: bool) -> bool {
169        if !self.retry_connection_errors {
170            return false;
171        }
172        if self.connect_only_before_send {
173            return pre_send;
174        }
175        true
176    }
177
178    /// Parse `retry-after-ms` (preferred) or `Retry-After` into a delay.
179    ///
180    /// Invalid values are ignored (`None`).
181    #[must_use]
182    pub fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
183        Self::parse_retry_after_at(headers, SystemTime::now())
184    }
185
186    /// [`Self::parse_retry_after`] with an injected `now` for HTTP-date values.
187    #[must_use]
188    pub fn parse_retry_after_at(headers: &HeaderMap, now: SystemTime) -> Option<Duration> {
189        if let Some(value) = headers.get("retry-after-ms") {
190            if let Ok(raw) = value.to_str() {
191                if let Ok(ms) = raw.trim().parse::<f64>() {
192                    if ms.is_finite() && ms >= 0.0 {
193                        return Some(duration_from_millis_f64(ms));
194                    }
195                }
196            }
197        }
198
199        let raw = headers.get("retry-after")?.to_str().ok()?.trim();
200        if let Ok(seconds) = raw.parse::<f64>() {
201            if seconds.is_finite() && seconds >= 0.0 {
202                return Some(duration_from_millis_f64(seconds * 1000.0));
203            }
204            return None;
205        }
206        let date = httpdate::parse_http_date(raw).ok()?;
207        let delay = date.duration_since(now).unwrap_or_default();
208        Some(delay)
209    }
210
211    /// Delay before the next attempt after a zero-based failed `attempt`.
212    #[must_use]
213    pub fn delay_after_failure(&self, attempt: u32, headers: Option<&HeaderMap>) -> Duration {
214        self.delay_after_failure_with(attempt, headers, fastrand::f64)
215    }
216
217    /// [`Self::delay_after_failure`] with an injected RNG in `[0, 1)`.
218    ///
219    /// Tests should call this instead of reimplementing backoff math.
220    pub fn delay_after_failure_with<F>(
221        &self,
222        attempt: u32,
223        headers: Option<&HeaderMap>,
224        rng: F,
225    ) -> Duration
226    where
227        F: FnOnce() -> f64,
228    {
229        if self.respect_retry_after {
230            if let Some(headers) = headers {
231                if let Some(delay) = Self::parse_retry_after(headers) {
232                    if delay <= self.max_retry_after {
233                        return delay;
234                    }
235                }
236            }
237        }
238
239        let factor = 1u32.checked_shl(attempt).unwrap_or(u32::MAX);
240        let exponential = self
241            .backoff_initial
242            .saturating_mul(factor)
243            .min(self.backoff_max);
244        let exp_ms = exponential.as_secs_f64() * 1000.0;
245        let jitter = rng().clamp(0.0, 1.0) * self.backoff_jitter;
246        duration_from_millis_f64((exp_ms * (1.0 - jitter)).round().max(0.0))
247    }
248}
249
250fn duration_from_millis_f64(ms: f64) -> Duration {
251    let nanos = (ms * 1_000_000.0).round().max(0.0) as u128;
252    let nanos = u64::try_from(nanos).unwrap_or(u64::MAX);
253    Duration::from_nanos(nanos)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use http::HeaderValue;
260
261    fn policy() -> RetryPolicy {
262        RetryPolicy::default()
263    }
264
265    #[test]
266    fn default_statuses_include_408_429_and_5xx() {
267        let s = StatusSet::retryable();
268        assert!(s.contains(408));
269        assert!(s.contains(429));
270        assert!(s.contains(500));
271        assert!(s.contains(529));
272        assert!(s.contains(599));
273        assert!(!s.contains(400));
274        assert!(!s.contains(422));
275    }
276
277    #[test]
278    fn delay_without_headers_zero_jitter_is_initial() {
279        let d = policy().delay_after_failure_with(0, None, || 0.0);
280        assert_eq!(d, Duration::from_millis(500));
281    }
282
283    #[test]
284    fn delay_full_jitter_subtracts_25_percent() {
285        let d = policy().delay_after_failure_with(0, None, || 1.0);
286        assert_eq!(d, Duration::from_millis(375));
287    }
288
289    #[test]
290    fn delay_doubles_per_attempt_until_cap() {
291        let p = policy();
292        assert_eq!(
293            p.delay_after_failure_with(1, None, || 0.0),
294            Duration::from_millis(1000)
295        );
296        assert_eq!(
297            p.delay_after_failure_with(2, None, || 0.0),
298            Duration::from_millis(2000)
299        );
300        assert_eq!(
301            p.delay_after_failure_with(4, None, || 0.0),
302            Duration::from_secs(5)
303        );
304    }
305
306    #[test]
307    fn retry_after_ms_wins_over_backoff() {
308        let mut headers = HeaderMap::new();
309        headers.insert("retry-after-ms", HeaderValue::from_static("120"));
310        let d = policy().delay_after_failure_with(0, Some(&headers), || 0.0);
311        assert_eq!(d, Duration::from_millis(120));
312    }
313
314    #[test]
315    fn retry_after_ms_over_max_falls_back_to_backoff() {
316        let mut headers = HeaderMap::new();
317        headers.insert("retry-after-ms", HeaderValue::from_static("70000"));
318        let d = policy().delay_after_failure_with(0, Some(&headers), || 0.0);
319        assert_eq!(d, Duration::from_millis(500));
320    }
321
322    #[test]
323    fn retry_after_seconds() {
324        let mut headers = HeaderMap::new();
325        headers.insert("retry-after", HeaderValue::from_static("2"));
326        let d = RetryPolicy::parse_retry_after(&headers).unwrap();
327        assert_eq!(d, Duration::from_secs(2));
328    }
329
330    #[test]
331    fn retry_after_http_date() {
332        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
333        let later = now + Duration::from_secs(4);
334        let mut headers = HeaderMap::new();
335        headers.insert(
336            "retry-after",
337            HeaderValue::from_str(&httpdate::fmt_http_date(later)).unwrap(),
338        );
339        let d = RetryPolicy::parse_retry_after_at(&headers, now).unwrap();
340        assert_eq!(d, Duration::from_secs(4));
341    }
342
343    #[test]
344    fn past_http_date_clamps_to_zero() {
345        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
346        let past = SystemTime::UNIX_EPOCH;
347        let mut headers = HeaderMap::new();
348        headers.insert(
349            "retry-after",
350            HeaderValue::from_str(&httpdate::fmt_http_date(past)).unwrap(),
351        );
352        let d = RetryPolicy::parse_retry_after_at(&headers, now).unwrap();
353        assert_eq!(d, Duration::ZERO);
354    }
355
356    #[test]
357    fn invalid_retry_after_ignored() {
358        let mut headers = HeaderMap::new();
359        headers.insert("retry-after-ms", HeaderValue::from_static("nope"));
360        headers.insert("retry-after", HeaderValue::from_static("also-nope"));
361        assert!(RetryPolicy::parse_retry_after(&headers).is_none());
362    }
363
364    #[test]
365    fn negative_retry_after_ignored() {
366        let mut headers = HeaderMap::new();
367        headers.insert("retry-after", HeaderValue::from_static("-1"));
368        assert!(RetryPolicy::parse_retry_after(&headers).is_none());
369    }
370
371    #[test]
372    fn retry_after_ms_preferred_over_retry_after() {
373        let mut headers = HeaderMap::new();
374        headers.insert("retry-after-ms", HeaderValue::from_static("50"));
375        headers.insert("retry-after", HeaderValue::from_static("9"));
376        let d = RetryPolicy::parse_retry_after(&headers).unwrap();
377        assert_eq!(d, Duration::from_millis(50));
378    }
379
380    #[test]
381    fn none_has_zero_retries() {
382        assert_eq!(RetryPolicy::none().max_retries, 0);
383    }
384
385    #[test]
386    fn conservative_skips_5xx_and_timeouts() {
387        let p = RetryPolicy::conservative();
388        assert!(!p.http_statuses.contains(500));
389        assert!(p.http_statuses.contains(429));
390        assert!(p.http_statuses.contains(408));
391        assert!(!p.retry_timeouts);
392        assert!(p.retry_connection_errors);
393        assert!(p.connect_only_before_send);
394        assert!(p.retries_connection(true));
395        assert!(!p.retries_connection(false));
396    }
397
398    #[test]
399    fn default_retries_all_connection_errors() {
400        let p = RetryPolicy::default();
401        assert!(p.retries_connection(true));
402        assert!(p.retries_connection(false));
403    }
404}