Skip to main content

typesafe/
retry.rs

1//! Retry configuration. Semantics match the Python SDK's `RetryPolicy` (Tenacity-based):
2//! exponential backoff with subtractive jitter, `Retry-After`/`retry-after-ms` support, a max
3//! retry count, and a total time budget that stops *before* a sleep that would exceed it.
4
5use std::collections::BTreeSet;
6use std::fmt;
7use std::sync::Arc;
8use std::time::Duration;
9
10use http::StatusCode;
11
12use crate::error::{Error, Result};
13
14/// Custom retry predicate, consulted in addition to the built-in rules.
15pub type RetryPredicate = Arc<dyn Fn(&Error) -> bool + Send + Sync>;
16
17/// How failed requests are retried.
18///
19/// Start from [`RetryPolicy::default`] or [`RetryPolicy::none`] and adjust with the builder methods
20/// (or set the public fields directly).
21#[derive(Clone)]
22#[non_exhaustive]
23pub struct RetryPolicy {
24    /// Retries after the first attempt; `0` disables retries. Default `2`.
25    pub max_retries: u32,
26    /// First backoff delay, doubled per attempt up to `backoff_max`; zero disables backoff. Default 0.5s.
27    pub backoff_initial: Duration,
28    /// Maximum backoff delay; zero disables backoff. Default 5s.
29    pub backoff_max: Duration,
30    /// Fraction of each delay randomly subtracted, in `[0, 1]`. Default `0.25`.
31    pub backoff_jitter: f64,
32    /// Statuses that are retried. Default 408, 429 and 500–599 (which includes TypeSafe's 529).
33    pub http_statuses: BTreeSet<StatusCode>,
34    /// Honor `retry-after-ms` / `Retry-After`. Default `true`.
35    pub respect_retry_after: bool,
36    /// Retry [`Error::Connection`]. Default `true`.
37    pub retry_connection_errors: bool,
38    /// Retry [`Error::Timeout`]. Default `true`.
39    pub retry_timeouts: bool,
40    /// Extra predicate; returning `true` also triggers a retry.
41    pub predicate: Option<RetryPredicate>,
42    /// Total budget per SDK call including attempts and delays; `None` = unlimited. Default 30s.
43    pub budget: Option<Duration>,
44}
45
46impl Default for RetryPolicy {
47    fn default() -> Self {
48        let mut statuses: BTreeSet<StatusCode> = (500..600)
49            .filter_map(|s| StatusCode::from_u16(s).ok())
50            .collect();
51        statuses.extend([StatusCode::REQUEST_TIMEOUT, StatusCode::TOO_MANY_REQUESTS]);
52        Self {
53            max_retries: 2,
54            backoff_initial: Duration::from_millis(500),
55            backoff_max: Duration::from_secs(5),
56            backoff_jitter: 0.25,
57            http_statuses: statuses,
58            respect_retry_after: true,
59            retry_connection_errors: true,
60            retry_timeouts: true,
61            predicate: None,
62            budget: Some(Duration::from_secs(30)),
63        }
64    }
65}
66
67impl fmt::Debug for RetryPolicy {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.debug_struct("RetryPolicy")
70            .field("max_retries", &self.max_retries)
71            .field("backoff_initial", &self.backoff_initial)
72            .field("backoff_max", &self.backoff_max)
73            .field("backoff_jitter", &self.backoff_jitter)
74            .field("http_statuses", &self.http_statuses)
75            .field("respect_retry_after", &self.respect_retry_after)
76            .field("retry_connection_errors", &self.retry_connection_errors)
77            .field("retry_timeouts", &self.retry_timeouts)
78            .field("predicate", &self.predicate.as_ref().map(|_| "<fn>"))
79            .field("budget", &self.budget)
80            .finish()
81    }
82}
83
84impl RetryPolicy {
85    /// A policy that never retries.
86    pub fn none() -> Self {
87        Self {
88            max_retries: 0,
89            ..Self::default()
90        }
91    }
92
93    /// Set `max_retries`.
94    #[must_use]
95    pub fn max_retries(mut self, n: u32) -> Self {
96        self.max_retries = n;
97        self
98    }
99
100    /// Set the backoff bounds.
101    #[must_use]
102    pub fn backoff(mut self, initial: Duration, max: Duration) -> Self {
103        self.backoff_initial = initial;
104        self.backoff_max = max;
105        self
106    }
107
108    /// Set the jitter fraction.
109    #[must_use]
110    pub fn jitter(mut self, fraction: f64) -> Self {
111        self.backoff_jitter = fraction;
112        self
113    }
114
115    /// Replace the retryable status set.
116    ///
117    /// ```
118    /// use typesafe::{RetryPolicy, StatusCode};
119    ///
120    /// let policy = RetryPolicy::default().statuses([
121    ///     StatusCode::TOO_MANY_REQUESTS,
122    ///     StatusCode::SERVICE_UNAVAILABLE,
123    /// ]);
124    /// assert!(policy.http_statuses.contains(&StatusCode::SERVICE_UNAVAILABLE));
125    /// ```
126    #[must_use]
127    pub fn statuses(mut self, statuses: impl IntoIterator<Item = StatusCode>) -> Self {
128        self.http_statuses = statuses.into_iter().collect();
129        self
130    }
131
132    /// Set the total time budget.
133    #[must_use]
134    pub fn budget(mut self, budget: Option<Duration>) -> Self {
135        self.budget = budget;
136        self
137    }
138
139    /// Whether to honor `retry-after-ms` / `Retry-After`.
140    #[must_use]
141    pub fn respect_retry_after(mut self, yes: bool) -> Self {
142        self.respect_retry_after = yes;
143        self
144    }
145
146    /// Whether to retry [`Error::Connection`].
147    #[must_use]
148    pub fn retry_connection_errors(mut self, yes: bool) -> Self {
149        self.retry_connection_errors = yes;
150        self
151    }
152
153    /// Whether to retry [`Error::Timeout`].
154    #[must_use]
155    pub fn retry_timeouts(mut self, yes: bool) -> Self {
156        self.retry_timeouts = yes;
157        self
158    }
159
160    /// Add a custom predicate.
161    #[must_use]
162    pub fn retry_if(mut self, f: impl Fn(&Error) -> bool + Send + Sync + 'static) -> Self {
163        self.predicate = Some(Arc::new(f));
164        self
165    }
166
167    pub(crate) fn validate(&self) -> Result<()> {
168        if !(0.0..=1.0).contains(&self.backoff_jitter) {
169            return Err(Error::config("backoff_jitter must be between zero and one"));
170        }
171        if self.budget == Some(Duration::ZERO) {
172            return Err(Error::config("retry budget must be a positive duration"));
173        }
174        Ok(())
175    }
176
177    pub(crate) fn is_retryable(&self, err: &Error) -> bool {
178        let builtin = match err {
179            Error::Timeout(_) => self.retry_timeouts,
180            Error::Connection(_) => self.retry_connection_errors,
181            Error::Api(e) => self.http_statuses.contains(&e.status),
182            _ => false,
183        };
184        builtin || self.predicate.as_ref().is_some_and(|p| p(err))
185    }
186
187    /// Delay before the next attempt; `attempt` is the 1-based number of the attempt that just failed.
188    pub(crate) fn delay(&self, attempt: u32, err: &Error) -> Duration {
189        if self.respect_retry_after
190            && let Some(d) = err.as_api().and_then(|e| e.retry_after())
191        {
192            return d;
193        }
194        backoff(
195            attempt,
196            self.backoff_initial,
197            self.backoff_max,
198            self.backoff_jitter,
199            rand::random::<f64>(),
200        )
201    }
202
203    /// Whether to stop instead of sleeping `upcoming` after `attempts` attempts and `elapsed` time.
204    pub(crate) fn should_stop(&self, attempts: u32, elapsed: Duration, upcoming: Duration) -> bool {
205        attempts > self.max_retries
206            || self
207                .budget
208                .is_some_and(|b| elapsed.saturating_add(upcoming) >= b)
209    }
210}
211
212fn backoff(attempt: u32, initial: Duration, max: Duration, jitter: f64, r: f64) -> Duration {
213    let (initial, max) = (initial.as_secs_f64(), max.as_secs_f64());
214    if initial == 0.0 || max == 0.0 {
215        return Duration::ZERO;
216    }
217    let exponent = attempt.saturating_sub(1) as f64;
218    let exponential = if exponent >= max.log2() - initial.log2() {
219        max
220    } else {
221        initial * 2f64.powf(exponent)
222    };
223    let delay = exponential * (1.0 - r * jitter);
224    let rounded = (delay * 1000.0).round() / 1000.0;
225    Duration::from_secs_f64(exponential.min(rounded))
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn backoff_doubles_and_caps() {
234        let (i, m) = (Duration::from_millis(500), Duration::from_secs(5));
235        let d = |a| backoff(a, i, m, 0.25, 0.0);
236        assert_eq!(d(1), Duration::from_millis(500));
237        assert_eq!(d(2), Duration::from_secs(1));
238        assert_eq!(d(4), Duration::from_secs(4));
239        assert_eq!(d(5), Duration::from_secs(5));
240        assert_eq!(d(40), Duration::from_secs(5));
241        // full jitter subtracts up to 25%
242        assert_eq!(backoff(1, i, m, 0.25, 1.0), Duration::from_millis(375));
243        assert_eq!(backoff(1, Duration::ZERO, m, 0.25, 0.5), Duration::ZERO);
244    }
245
246    #[test]
247    fn stop_rules() {
248        let p = RetryPolicy::default();
249        assert!(!p.should_stop(1, Duration::ZERO, Duration::from_secs(1)));
250        assert!(!p.should_stop(2, Duration::ZERO, Duration::from_secs(1)));
251        assert!(p.should_stop(3, Duration::ZERO, Duration::from_secs(1)));
252        assert!(p.should_stop(1, Duration::from_secs(29), Duration::from_secs(1)));
253        assert!(!RetryPolicy::default().budget(None).should_stop(
254            1,
255            Duration::from_secs(99),
256            Duration::from_secs(1)
257        ));
258    }
259
260    #[test]
261    fn default_statuses_cover_529() {
262        let p = RetryPolicy::default();
263        for s in [408, 429, 500, 503, 529, 599] {
264            assert!(p.http_statuses.contains(&StatusCode::from_u16(s).unwrap()));
265        }
266        assert!(!p.http_statuses.contains(&StatusCode::UNPROCESSABLE_ENTITY));
267        assert!(RetryPolicy::default().jitter(1.5).validate().is_err());
268    }
269}