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