Skip to main content

finance_query/providers/
retry.rs

1//! Opt-in retry policy for provider dispatch ([`RetryPolicy`]).
2
3use std::time::Duration;
4
5/// Retry policy for [`super::ProviderSet`] dispatch, opt-in via
6/// [`crate::Providers::builder`]`().`[`retry`](super::config::ProvidersBuilder::retry)`(..)`.
7///
8/// When configured, a candidate provider that returns
9/// [`FinanceError::RateLimited`](crate::error::FinanceError::RateLimited) is
10/// retried in place — honoring the error's `retry_after` hint when present
11/// (capped by `max_retry_after`), or this policy's own
12/// exponential-backoff-plus-jitter delay otherwise — up to `max_attempts`
13/// times before dispatch falls through to the next routed provider (or
14/// fails, if it was the last).
15///
16/// Other error kinds are never retried by this policy: a `RateLimited` is the
17/// one error a policy-level retry can reliably fix by waiting; anything else
18/// (auth failures, 5xx, not-found, ...) is left to the existing
19/// sequential/parallel provider fallback.
20///
21/// **Default is no retry** — a [`Providers`](crate::Providers) built without
22/// calling `.retry(..)` behaves exactly as before this policy existed.
23///
24/// # Example
25///
26/// ```no_run
27/// use finance_query::{Providers, RetryPolicy};
28/// use std::time::Duration;
29///
30/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
31/// let providers = Providers::builder()
32///     .retry(RetryPolicy::new(3).base_delay(Duration::from_millis(500)))
33///     .build()
34///     .await?;
35/// # Ok(())
36/// # }
37/// ```
38#[derive(Debug, Clone, Copy, PartialEq)]
39#[non_exhaustive]
40pub struct RetryPolicy {
41    /// Maximum number of attempts per provider candidate, including the
42    /// first try. Values below `1` are treated as `1` (no retry).
43    pub max_attempts: u32,
44    /// Delay before the first retry when the error carries no `retry_after`
45    /// hint. Default: 500ms.
46    pub base_delay: Duration,
47    /// Multiplier applied to the delay after each retry (clamped to at least
48    /// `1.0`). Default: `2.0`.
49    pub multiplier: f64,
50    /// Jitter fraction (`0.0..=1.0`) applied to the computed delay so many
51    /// concurrent callers don't retry in lockstep. Default: `0.2`.
52    pub jitter: f64,
53    /// Upper bound on the computed backoff delay. Default: 30s.
54    pub max_delay: Duration,
55    /// Upper bound on an explicit `retry_after` hint. Separate from
56    /// `max_delay` so a legitimate multi-minute hint is honored, while a
57    /// hostile or buggy upstream can't park the caller indefinitely.
58    /// Default: 5 minutes.
59    pub max_retry_after: Duration,
60}
61
62impl RetryPolicy {
63    /// A policy allowing up to `max_attempts` tries per candidate provider,
64    /// with sane defaults for the rest (500ms base delay, 2x multiplier, 20%
65    /// jitter, 30s cap).
66    pub fn new(max_attempts: u32) -> Self {
67        Self {
68            max_attempts: max_attempts.max(1),
69            base_delay: Duration::from_millis(500),
70            multiplier: 2.0,
71            jitter: 0.2,
72            max_delay: Duration::from_secs(30),
73            max_retry_after: Duration::from_secs(300),
74        }
75    }
76
77    /// Override the base delay (see [`RetryPolicy::base_delay`] field docs).
78    pub fn base_delay(mut self, delay: Duration) -> Self {
79        self.base_delay = delay;
80        self
81    }
82
83    /// Override the backoff multiplier (see [`RetryPolicy::multiplier`] field docs).
84    pub fn multiplier(mut self, multiplier: f64) -> Self {
85        self.multiplier = multiplier;
86        self
87    }
88
89    /// Override the jitter fraction (see [`RetryPolicy::jitter`] field docs).
90    pub fn jitter(mut self, jitter: f64) -> Self {
91        self.jitter = jitter.clamp(0.0, 1.0);
92        self
93    }
94
95    /// Override the max delay cap (see [`RetryPolicy::max_delay`] field docs).
96    pub fn max_delay(mut self, max_delay: Duration) -> Self {
97        self.max_delay = max_delay;
98        self
99    }
100
101    /// Override the cap on an explicit `retry_after` hint
102    /// (see [`RetryPolicy::max_retry_after`] field docs).
103    pub fn max_retry_after(mut self, max_retry_after: Duration) -> Self {
104        self.max_retry_after = max_retry_after;
105        self
106    }
107
108    /// Delay before the retry numbered `attempt` (0-indexed: `0` is the delay
109    /// before the first retry), honoring an explicit `retry_after` hint when
110    /// present, capped at [`max_retry_after`](Self::max_retry_after).
111    pub(crate) fn delay_for(
112        &self,
113        attempt: u32,
114        retry_after: Option<Duration>,
115        seed: &mut u64,
116    ) -> Duration {
117        match retry_after {
118            Some(explicit) => explicit.min(self.max_retry_after),
119            None => crate::backoff::BackoffParams {
120                base: self.base_delay,
121                max: self.max_delay,
122                multiplier: self.multiplier,
123                jitter: self.jitter,
124            }
125            .delay_for(attempt, seed),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn new_clamps_zero_attempts_to_one() {
136        assert_eq!(RetryPolicy::new(0).max_attempts, 1);
137    }
138
139    #[test]
140    fn defaults_are_sane() {
141        let policy = RetryPolicy::new(3);
142        assert_eq!(policy.max_attempts, 3);
143        assert_eq!(policy.base_delay, Duration::from_millis(500));
144        assert_eq!(policy.multiplier, 2.0);
145        assert_eq!(policy.jitter, 0.2);
146        assert_eq!(policy.max_delay, Duration::from_secs(30));
147        assert_eq!(policy.max_retry_after, Duration::from_secs(300));
148    }
149
150    #[test]
151    fn jitter_is_clamped() {
152        assert_eq!(RetryPolicy::new(1).jitter(5.0).jitter, 1.0);
153        assert_eq!(RetryPolicy::new(1).jitter(-5.0).jitter, 0.0);
154    }
155
156    #[test]
157    fn explicit_retry_after_is_honored_independently_of_max_delay() {
158        // max_delay bounds the computed backoff, not the hint.
159        let policy = RetryPolicy::new(3).max_delay(Duration::from_secs(1));
160        let mut seed = 42;
161        let delay = policy.delay_for(5, Some(Duration::from_secs(120)), &mut seed);
162        assert_eq!(delay, Duration::from_secs(120));
163    }
164
165    #[test]
166    fn an_absurd_retry_after_hint_is_capped() {
167        // A hostile or buggy upstream must not park the caller for a day.
168        let policy = RetryPolicy::new(3);
169        let mut seed = 42;
170        let delay = policy.delay_for(0, Some(Duration::from_secs(86_400)), &mut seed);
171        assert_eq!(delay, Duration::from_secs(300));
172    }
173
174    #[test]
175    fn falls_back_to_exponential_backoff_when_no_retry_after() {
176        let policy = RetryPolicy::new(5)
177            .base_delay(Duration::from_secs(1))
178            .multiplier(2.0)
179            .jitter(0.0)
180            .max_delay(Duration::from_secs(10));
181        let mut seed = 1;
182        assert_eq!(policy.delay_for(0, None, &mut seed), Duration::from_secs(1));
183        assert_eq!(policy.delay_for(1, None, &mut seed), Duration::from_secs(2));
184        assert_eq!(policy.delay_for(2, None, &mut seed), Duration::from_secs(4));
185        // Capped.
186        assert_eq!(
187            policy.delay_for(10, None, &mut seed),
188            Duration::from_secs(10)
189        );
190    }
191}