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}