1use std::time::Duration;
9
10#[cfg(any(feature = "async", feature = "sync"))]
11use tracing::warn;
12
13use crate::error::Error;
14
15#[derive(Debug, Clone)]
33pub struct RetryPolicy {
34 pub(crate) max_attempts: u32,
35 pub(crate) initial_backoff: Duration,
36 pub(crate) max_backoff: Duration,
37 pub(crate) backoff_strategy: BackoffStrategy,
38 pub(crate) retry_on_timeout: bool,
39 pub(crate) retry_exit_codes: Vec<i32>,
40}
41
42#[derive(Debug, Clone, Copy)]
44pub enum BackoffStrategy {
45 Fixed,
47 Exponential,
49}
50
51impl Default for RetryPolicy {
52 fn default() -> Self {
53 Self {
54 max_attempts: 3,
55 initial_backoff: Duration::from_secs(1),
56 max_backoff: Duration::from_secs(30),
57 backoff_strategy: BackoffStrategy::Fixed,
58 retry_on_timeout: true,
59 retry_exit_codes: Vec::new(),
60 }
61 }
62}
63
64impl RetryPolicy {
65 #[must_use]
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 #[must_use]
75 pub fn max_attempts(mut self, n: u32) -> Self {
76 self.max_attempts = n;
77 self
78 }
79
80 #[must_use]
82 pub fn initial_backoff(mut self, duration: Duration) -> Self {
83 self.initial_backoff = duration;
84 self
85 }
86
87 #[must_use]
89 pub fn max_backoff(mut self, duration: Duration) -> Self {
90 self.max_backoff = duration;
91 self
92 }
93
94 #[must_use]
96 pub fn fixed(mut self) -> Self {
97 self.backoff_strategy = BackoffStrategy::Fixed;
98 self
99 }
100
101 #[must_use]
103 pub fn exponential(mut self) -> Self {
104 self.backoff_strategy = BackoffStrategy::Exponential;
105 self
106 }
107
108 #[must_use]
110 pub fn retry_on_timeout(mut self, retry: bool) -> Self {
111 self.retry_on_timeout = retry;
112 self
113 }
114
115 #[must_use]
117 pub fn retry_on_exit_codes(mut self, codes: impl IntoIterator<Item = i32>) -> Self {
118 self.retry_exit_codes = codes.into_iter().collect();
119 self
120 }
121
122 #[allow(dead_code)] pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
125 let delay = match self.backoff_strategy {
126 BackoffStrategy::Fixed => self.initial_backoff,
127 BackoffStrategy::Exponential => self
128 .initial_backoff
129 .saturating_mul(2u32.saturating_pow(attempt)),
130 };
131 delay.min(self.max_backoff)
132 }
133
134 #[allow(dead_code)] pub(crate) fn should_retry(&self, error: &Error) -> bool {
137 match error {
138 Error::Timeout { .. } => self.retry_on_timeout,
139 Error::CommandFailed { exit_code, .. } => self.retry_exit_codes.contains(exit_code),
140 _ => false,
141 }
142 }
143}
144
145#[cfg(feature = "async")]
147pub(crate) async fn with_retry<F, Fut, T>(
148 policy: &RetryPolicy,
149 mut operation: F,
150) -> crate::error::Result<T>
151where
152 F: FnMut() -> Fut,
153 Fut: std::future::Future<Output = crate::error::Result<T>>,
154{
155 let mut last_error = None;
156
157 for attempt in 0..policy.max_attempts {
158 match operation().await {
159 Ok(result) => return Ok(result),
160 Err(e) => {
161 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
162 let delay = policy.delay_for_attempt(attempt);
163 warn!(
164 attempt = attempt + 1,
165 max_attempts = policy.max_attempts,
166 delay_ms = delay.as_millis() as u64,
167 error = %e,
168 "retrying after transient error"
169 );
170 tokio::time::sleep(delay).await;
171 last_error = Some(e);
172 } else {
173 return Err(e);
174 }
175 }
176 }
177 }
178
179 Err(last_error.expect("at least one attempt was made"))
180}
181
182#[cfg(feature = "sync")]
185pub(crate) fn with_retry_sync<F, T>(
186 policy: &RetryPolicy,
187 mut operation: F,
188) -> crate::error::Result<T>
189where
190 F: FnMut() -> crate::error::Result<T>,
191{
192 let mut last_error = None;
193
194 for attempt in 0..policy.max_attempts {
195 match operation() {
196 Ok(result) => return Ok(result),
197 Err(e) => {
198 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
199 let delay = policy.delay_for_attempt(attempt);
200 warn!(
201 attempt = attempt + 1,
202 max_attempts = policy.max_attempts,
203 delay_ms = delay.as_millis() as u64,
204 error = %e,
205 "retrying after transient error"
206 );
207 std::thread::sleep(delay);
208 last_error = Some(e);
209 } else {
210 return Err(e);
211 }
212 }
213 }
214 }
215
216 Err(last_error.expect("at least one attempt was made"))
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn test_default_policy() {
225 let policy = RetryPolicy::new();
226 assert_eq!(policy.max_attempts, 3);
227 assert_eq!(policy.initial_backoff, Duration::from_secs(1));
228 assert!(policy.retry_on_timeout);
229 assert!(policy.retry_exit_codes.is_empty());
230 }
231
232 #[test]
233 fn test_builder() {
234 let policy = RetryPolicy::new()
235 .max_attempts(5)
236 .initial_backoff(Duration::from_millis(500))
237 .exponential()
238 .retry_on_timeout(false)
239 .retry_on_exit_codes([1, 2, 3]);
240
241 assert_eq!(policy.max_attempts, 5);
242 assert_eq!(policy.initial_backoff, Duration::from_millis(500));
243 assert!(!policy.retry_on_timeout);
244 assert_eq!(policy.retry_exit_codes, vec![1, 2, 3]);
245 }
246
247 #[test]
248 fn test_fixed_delay() {
249 let policy = RetryPolicy::new()
250 .initial_backoff(Duration::from_secs(2))
251 .fixed();
252
253 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(2));
254 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
255 assert_eq!(policy.delay_for_attempt(5), Duration::from_secs(2));
256 }
257
258 #[test]
259 fn test_exponential_delay() {
260 let policy = RetryPolicy::new()
261 .initial_backoff(Duration::from_secs(1))
262 .max_backoff(Duration::from_secs(30))
263 .exponential();
264
265 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
266 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
267 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(4));
268 assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(8));
269 assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(30));
271 }
272
273 #[test]
274 fn test_should_retry_timeout() {
275 let policy = RetryPolicy::new().retry_on_timeout(true);
276 let error = Error::Timeout {
277 timeout_seconds: 60,
278 };
279 assert!(policy.should_retry(&error));
280
281 let policy = RetryPolicy::new().retry_on_timeout(false);
282 assert!(!policy.should_retry(&error));
283 }
284
285 #[test]
286 fn test_should_retry_exit_code() {
287 let policy = RetryPolicy::new().retry_on_exit_codes([1, 2]);
288
289 let retryable = Error::CommandFailed {
290 command: "test".into(),
291 exit_code: 1,
292 stdout: String::new(),
293 stderr: String::new(),
294 working_dir: None,
295 };
296 assert!(policy.should_retry(&retryable));
297
298 let not_retryable = Error::CommandFailed {
299 command: "test".into(),
300 exit_code: 99,
301 stdout: String::new(),
302 stderr: String::new(),
303 working_dir: None,
304 };
305 assert!(!policy.should_retry(¬_retryable));
306 }
307
308 #[test]
309 fn test_should_not_retry_other_errors() {
310 let policy = RetryPolicy::new()
311 .retry_on_timeout(true)
312 .retry_on_exit_codes([1]);
313
314 let error = Error::NotFound;
315 assert!(!policy.should_retry(&error));
316 }
317
318 #[cfg(feature = "async")]
319 #[tokio::test]
320 async fn test_with_retry_succeeds_first_try() {
321 let policy = RetryPolicy::new().max_attempts(3);
322 let result = with_retry(&policy, || async { Ok::<_, Error>(42) }).await;
323 assert_eq!(result.unwrap(), 42);
324 }
325
326 #[cfg(feature = "async")]
327 #[tokio::test]
328 async fn test_with_retry_succeeds_after_failures() {
329 let policy = RetryPolicy::new()
330 .max_attempts(3)
331 .initial_backoff(Duration::from_millis(1))
332 .retry_on_timeout(true);
333
334 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
335 let attempt_clone = attempt.clone();
336
337 let result = with_retry(&policy, || {
338 let attempt = attempt_clone.clone();
339 async move {
340 let n = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
341 if n < 2 {
342 Err(Error::Timeout {
343 timeout_seconds: 60,
344 })
345 } else {
346 Ok(42)
347 }
348 }
349 })
350 .await;
351
352 assert_eq!(result.unwrap(), 42);
353 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 3);
354 }
355
356 #[cfg(feature = "async")]
357 #[tokio::test]
358 async fn test_with_retry_exhausts_attempts() {
359 let policy = RetryPolicy::new()
360 .max_attempts(2)
361 .initial_backoff(Duration::from_millis(1))
362 .retry_on_timeout(true);
363
364 let result: crate::error::Result<()> = with_retry(&policy, || async {
365 Err(Error::Timeout {
366 timeout_seconds: 60,
367 })
368 })
369 .await;
370
371 assert!(matches!(result, Err(Error::Timeout { .. })));
372 }
373
374 #[cfg(feature = "async")]
375 #[tokio::test]
376 async fn test_with_retry_no_retry_on_non_retryable() {
377 let policy = RetryPolicy::new()
378 .max_attempts(3)
379 .initial_backoff(Duration::from_millis(1))
380 .retry_on_timeout(false);
381
382 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
383 let attempt_clone = attempt.clone();
384
385 let result: crate::error::Result<()> = with_retry(&policy, || {
386 let attempt = attempt_clone.clone();
387 async move {
388 attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
389 Err(Error::Timeout {
390 timeout_seconds: 60,
391 })
392 }
393 })
394 .await;
395
396 assert!(result.is_err());
397 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 1);
399 }
400
401 #[cfg(feature = "sync")]
402 #[test]
403 fn test_with_retry_sync_succeeds_first_try() {
404 let policy = RetryPolicy::new().max_attempts(3);
405 let result = with_retry_sync(&policy, || Ok::<_, Error>(42));
406 assert_eq!(result.unwrap(), 42);
407 }
408
409 #[cfg(feature = "sync")]
410 #[test]
411 fn test_with_retry_sync_succeeds_after_failures() {
412 use std::sync::atomic::{AtomicU32, Ordering};
413
414 let policy = RetryPolicy::new()
415 .max_attempts(3)
416 .initial_backoff(Duration::from_millis(1))
417 .retry_on_timeout(true);
418
419 let attempt = AtomicU32::new(0);
420 let result = with_retry_sync(&policy, || {
421 let n = attempt.fetch_add(1, Ordering::SeqCst);
422 if n < 2 {
423 Err(Error::Timeout {
424 timeout_seconds: 60,
425 })
426 } else {
427 Ok(42)
428 }
429 });
430
431 assert_eq!(result.unwrap(), 42);
432 assert_eq!(attempt.load(Ordering::SeqCst), 3);
433 }
434
435 #[cfg(feature = "sync")]
436 #[test]
437 fn test_with_retry_sync_exhausts_attempts() {
438 let policy = RetryPolicy::new()
439 .max_attempts(2)
440 .initial_backoff(Duration::from_millis(1))
441 .retry_on_timeout(true);
442
443 let result: crate::error::Result<()> = with_retry_sync(&policy, || {
444 Err(Error::Timeout {
445 timeout_seconds: 60,
446 })
447 });
448
449 assert!(matches!(result, Err(Error::Timeout { .. })));
450 }
451
452 #[cfg(feature = "sync")]
453 #[test]
454 fn test_with_retry_sync_no_retry_on_non_retryable() {
455 use std::sync::atomic::{AtomicU32, Ordering};
456
457 let policy = RetryPolicy::new()
458 .max_attempts(3)
459 .initial_backoff(Duration::from_millis(1))
460 .retry_on_timeout(false);
461
462 let attempt = AtomicU32::new(0);
463 let result: crate::error::Result<()> = with_retry_sync(&policy, || {
464 attempt.fetch_add(1, Ordering::SeqCst);
465 Err(Error::Timeout {
466 timeout_seconds: 60,
467 })
468 });
469
470 assert!(result.is_err());
471 assert_eq!(attempt.load(Ordering::SeqCst), 1);
472 }
473}