1use std::time::Duration;
14
15use rand::RngExt;
16use serde::{Deserialize, Serialize};
17use tracing::warn;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RetryConfig {
48 pub max_retries: u32,
50 pub initial_delay: Duration,
52 pub max_delay: Duration,
54 pub backoff_multiplier: f64,
56 pub jitter: bool,
58}
59
60impl Default for RetryConfig {
61 fn default() -> Self {
62 Self {
63 max_retries: 3,
64 initial_delay: Duration::from_secs(1),
65 max_delay: Duration::from_secs(60),
66 backoff_multiplier: 2.0,
67 jitter: true,
68 }
69 }
70}
71
72impl RetryConfig {
73 pub fn new() -> Self {
75 Self::default()
76 }
77
78 pub fn none() -> Self {
80 Self {
81 max_retries: 0,
82 ..Self::default()
83 }
84 }
85
86 pub fn with_max_retries(mut self, n: u32) -> Self {
88 self.max_retries = n;
89 self
90 }
91
92 pub fn with_initial_delay(mut self, delay: Duration) -> Self {
94 self.initial_delay = delay;
95 self
96 }
97
98 pub fn with_max_delay(mut self, delay: Duration) -> Self {
100 self.max_delay = delay;
101 self
102 }
103
104 pub fn with_backoff_multiplier(mut self, multiplier: f64) -> Self {
106 self.backoff_multiplier = multiplier;
107 self
108 }
109
110 pub fn with_jitter(mut self, jitter: bool) -> Self {
112 self.jitter = jitter;
113 self
114 }
115
116 pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
118 let base = self.initial_delay.as_secs_f64() * self.backoff_multiplier.powi(attempt as i32);
119 let capped = base.min(self.max_delay.as_secs_f64());
120
121 let final_delay = if self.jitter {
122 let jitter_range = capped * 0.5;
123 let jitter_offset = rand::rng().random_range(0.0..jitter_range);
124 capped + jitter_offset
125 } else {
126 capped
127 };
128
129 Duration::from_secs_f64(final_delay)
130 }
131}
132
133pub(crate) async fn with_retry<F, Fut, T>(
139 config: &RetryConfig,
140 operation_name: &str,
141 mut operation: F,
142) -> crate::error::Result<T>
143where
144 F: FnMut() -> Fut,
145 Fut: std::future::Future<Output = crate::error::Result<T>>,
146{
147 for attempt in 0..=config.max_retries {
148 match operation().await {
149 Ok(result) => return Ok(result),
150 Err(e) => {
151 if !e.is_retryable() || attempt == config.max_retries {
152 return Err(e);
153 }
154
155 let delay = config.delay_for_attempt(attempt);
156 warn!(
157 operation = operation_name,
158 attempt = attempt + 1,
159 max_retries = config.max_retries,
160 delay_ms = delay.as_millis() as u64,
161 error_kind = e.kind_str(),
162 provider = ?e.provider(),
163 error = %e,
164 "Retrying after transient error"
165 );
166
167 tokio::time::sleep(delay).await;
168 }
169 }
170 }
171
172 unreachable!("retry loop should have returned")
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn default_config() {
182 let config = RetryConfig::default();
183 assert_eq!(config.max_retries, 3);
184 assert_eq!(config.initial_delay, Duration::from_secs(1));
185 assert_eq!(config.max_delay, Duration::from_secs(60));
186 assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
187 assert!(config.jitter);
188 }
189
190 #[test]
191 fn none_config() {
192 let config = RetryConfig::none();
193 assert_eq!(config.max_retries, 0);
194 }
195
196 #[test]
197 fn delay_exponential_growth() {
198 let config = RetryConfig::new().with_jitter(false);
199
200 let d0 = config.delay_for_attempt(0);
201 let d1 = config.delay_for_attempt(1);
202 let d2 = config.delay_for_attempt(2);
203
204 assert_eq!(d0, Duration::from_secs(1)); assert_eq!(d1, Duration::from_secs(2)); assert_eq!(d2, Duration::from_secs(4)); }
208
209 #[test]
210 fn delay_capped_at_max() {
211 let config = RetryConfig::new()
212 .with_jitter(false)
213 .with_max_delay(Duration::from_secs(3));
214
215 let d0 = config.delay_for_attempt(0); let d1 = config.delay_for_attempt(1); let d2 = config.delay_for_attempt(2); let d3 = config.delay_for_attempt(3); assert_eq!(d0, Duration::from_secs(1));
221 assert_eq!(d1, Duration::from_secs(2));
222 assert_eq!(d2, Duration::from_secs(3));
223 assert_eq!(d3, Duration::from_secs(3));
224 }
225
226 #[test]
227 fn delay_with_jitter_is_bounded() {
228 let config = RetryConfig::new().with_jitter(true);
229
230 for attempt in 0..5 {
232 let base =
233 config.initial_delay.as_secs_f64() * config.backoff_multiplier.powi(attempt as i32);
234 let capped = base.min(config.max_delay.as_secs_f64());
235
236 let delay = config.delay_for_attempt(attempt);
237 let delay_secs = delay.as_secs_f64();
238
239 assert!(
240 delay_secs >= capped,
241 "attempt {attempt}: delay {delay_secs} < base {capped}"
242 );
243 assert!(
244 delay_secs < capped * 1.5,
245 "attempt {attempt}: delay {delay_secs} >= max {:.2}",
246 capped * 1.5
247 );
248 }
249 }
250
251 #[test]
252 fn builder_chain() {
253 let config = RetryConfig::new()
254 .with_max_retries(5)
255 .with_initial_delay(Duration::from_millis(500))
256 .with_max_delay(Duration::from_secs(30))
257 .with_backoff_multiplier(3.0)
258 .with_jitter(false);
259
260 assert_eq!(config.max_retries, 5);
261 assert_eq!(config.initial_delay, Duration::from_millis(500));
262 assert_eq!(config.max_delay, Duration::from_secs(30));
263 assert!((config.backoff_multiplier - 3.0).abs() < f64::EPSILON);
264 assert!(!config.jitter);
265 }
266}