1use std::time::Duration;
2
3use tracing::warn;
4
5use crate::error::Error;
6
7#[derive(Debug, Clone)]
25pub struct RetryPolicy {
26 pub(crate) max_attempts: u32,
27 pub(crate) initial_backoff: Duration,
28 pub(crate) max_backoff: Duration,
29 pub(crate) backoff_strategy: BackoffStrategy,
30 pub(crate) retry_on_timeout: bool,
31 pub(crate) retry_exit_codes: Vec<i32>,
32}
33
34#[derive(Debug, Clone, Copy)]
36pub enum BackoffStrategy {
37 Fixed,
39 Exponential,
41}
42
43impl Default for RetryPolicy {
44 fn default() -> Self {
45 Self {
46 max_attempts: 3,
47 initial_backoff: Duration::from_secs(1),
48 max_backoff: Duration::from_secs(30),
49 backoff_strategy: BackoffStrategy::Fixed,
50 retry_on_timeout: true,
51 retry_exit_codes: Vec::new(),
52 }
53 }
54}
55
56impl RetryPolicy {
57 #[must_use]
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
67 pub fn max_attempts(mut self, n: u32) -> Self {
68 self.max_attempts = n;
69 self
70 }
71
72 #[must_use]
74 pub fn initial_backoff(mut self, duration: Duration) -> Self {
75 self.initial_backoff = duration;
76 self
77 }
78
79 #[must_use]
81 pub fn max_backoff(mut self, duration: Duration) -> Self {
82 self.max_backoff = duration;
83 self
84 }
85
86 #[must_use]
88 pub fn fixed(mut self) -> Self {
89 self.backoff_strategy = BackoffStrategy::Fixed;
90 self
91 }
92
93 #[must_use]
95 pub fn exponential(mut self) -> Self {
96 self.backoff_strategy = BackoffStrategy::Exponential;
97 self
98 }
99
100 #[must_use]
102 pub fn retry_on_timeout(mut self, retry: bool) -> Self {
103 self.retry_on_timeout = retry;
104 self
105 }
106
107 #[must_use]
109 pub fn retry_on_exit_codes(mut self, codes: impl IntoIterator<Item = i32>) -> Self {
110 self.retry_exit_codes = codes.into_iter().collect();
111 self
112 }
113
114 pub(crate) fn delay_for_attempt(&self, attempt: u32) -> Duration {
116 let delay = match self.backoff_strategy {
117 BackoffStrategy::Fixed => self.initial_backoff,
118 BackoffStrategy::Exponential => self
119 .initial_backoff
120 .saturating_mul(2u32.saturating_pow(attempt)),
121 };
122 delay.min(self.max_backoff)
123 }
124
125 pub(crate) fn should_retry(&self, error: &Error) -> bool {
127 match error {
128 Error::Timeout { .. } => self.retry_on_timeout,
129 _ if error.is_deterministic_failure() => false,
134 Error::CommandFailed { exit_code, .. } => self.retry_exit_codes.contains(exit_code),
135 _ => false,
136 }
137 }
138}
139
140pub(crate) async fn with_retry<F, Fut, T>(
142 policy: &RetryPolicy,
143 mut operation: F,
144) -> crate::error::Result<T>
145where
146 F: FnMut() -> Fut,
147 Fut: std::future::Future<Output = crate::error::Result<T>>,
148{
149 let mut last_error = None;
150
151 for attempt in 0..policy.max_attempts {
152 match operation().await {
153 Ok(result) => return Ok(result),
154 Err(e) => {
155 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
156 let delay = policy.delay_for_attempt(attempt);
157 warn!(
158 attempt = attempt + 1,
159 max_attempts = policy.max_attempts,
160 delay_ms = delay.as_millis() as u64,
161 error = %e,
162 "retrying after transient error"
163 );
164 tokio::time::sleep(delay).await;
165 last_error = Some(e);
166 } else {
167 return Err(e);
168 }
169 }
170 }
171 }
172
173 Err(last_error.expect("at least one attempt was made"))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_default_policy() {
182 let policy = RetryPolicy::new();
183 assert_eq!(policy.max_attempts, 3);
184 assert_eq!(policy.initial_backoff, Duration::from_secs(1));
185 assert!(policy.retry_on_timeout);
186 assert!(policy.retry_exit_codes.is_empty());
187 }
188
189 #[test]
190 fn test_builder() {
191 let policy = RetryPolicy::new()
192 .max_attempts(5)
193 .initial_backoff(Duration::from_millis(500))
194 .exponential()
195 .retry_on_timeout(false)
196 .retry_on_exit_codes([1, 2, 3]);
197
198 assert_eq!(policy.max_attempts, 5);
199 assert_eq!(policy.initial_backoff, Duration::from_millis(500));
200 assert!(!policy.retry_on_timeout);
201 assert_eq!(policy.retry_exit_codes, vec![1, 2, 3]);
202 }
203
204 #[test]
205 fn test_fixed_delay() {
206 let policy = RetryPolicy::new()
207 .initial_backoff(Duration::from_secs(2))
208 .fixed();
209
210 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(2));
211 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
212 assert_eq!(policy.delay_for_attempt(5), Duration::from_secs(2));
213 }
214
215 #[test]
216 fn test_exponential_delay() {
217 let policy = RetryPolicy::new()
218 .initial_backoff(Duration::from_secs(1))
219 .max_backoff(Duration::from_secs(30))
220 .exponential();
221
222 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
223 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
224 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(4));
225 assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(8));
226 assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(30));
228 }
229
230 #[test]
231 fn test_should_retry_timeout() {
232 let policy = RetryPolicy::new().retry_on_timeout(true);
233 let error = Error::Timeout {
234 timeout_seconds: 60,
235 };
236 assert!(policy.should_retry(&error));
237
238 let policy = RetryPolicy::new().retry_on_timeout(false);
239 assert!(!policy.should_retry(&error));
240 }
241
242 #[test]
243 fn test_should_retry_exit_code() {
244 let policy = RetryPolicy::new().retry_on_exit_codes([1, 2]);
245
246 let retryable = Error::CommandFailed {
247 command: "test".into(),
248 exit_code: 1,
249 stdout: String::new(),
250 stderr: String::new(),
251 working_dir: None,
252 };
253 assert!(policy.should_retry(&retryable));
254
255 let not_retryable = Error::CommandFailed {
256 command: "test".into(),
257 exit_code: 99,
258 stdout: String::new(),
259 stderr: String::new(),
260 working_dir: None,
261 };
262 assert!(!policy.should_retry(¬_retryable));
263 }
264
265 #[test]
266 fn test_should_not_retry_other_errors() {
267 let policy = RetryPolicy::new()
268 .retry_on_timeout(true)
269 .retry_on_exit_codes([1]);
270
271 let error = Error::NotFound;
272 assert!(!policy.should_retry(&error));
273 }
274
275 #[tokio::test]
276 async fn test_with_retry_succeeds_first_try() {
277 let policy = RetryPolicy::new().max_attempts(3);
278 let result = with_retry(&policy, || async { Ok::<_, Error>(42) }).await;
279 assert_eq!(result.unwrap(), 42);
280 }
281
282 #[tokio::test]
283 async fn test_with_retry_succeeds_after_failures() {
284 let policy = RetryPolicy::new()
285 .max_attempts(3)
286 .initial_backoff(Duration::from_millis(1))
287 .retry_on_timeout(true);
288
289 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
290 let attempt_clone = attempt.clone();
291
292 let result = with_retry(&policy, || {
293 let attempt = attempt_clone.clone();
294 async move {
295 let n = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
296 if n < 2 {
297 Err(Error::Timeout {
298 timeout_seconds: 60,
299 })
300 } else {
301 Ok(42)
302 }
303 }
304 })
305 .await;
306
307 assert_eq!(result.unwrap(), 42);
308 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 3);
309 }
310
311 #[tokio::test]
312 async fn test_with_retry_exhausts_attempts() {
313 let policy = RetryPolicy::new()
314 .max_attempts(2)
315 .initial_backoff(Duration::from_millis(1))
316 .retry_on_timeout(true);
317
318 let result: crate::error::Result<()> = with_retry(&policy, || async {
319 Err(Error::Timeout {
320 timeout_seconds: 60,
321 })
322 })
323 .await;
324
325 assert!(matches!(result, Err(Error::Timeout { .. })));
326 }
327
328 #[tokio::test]
329 async fn test_with_retry_no_retry_on_non_retryable() {
330 let policy = RetryPolicy::new()
331 .max_attempts(3)
332 .initial_backoff(Duration::from_millis(1))
333 .retry_on_timeout(false);
334
335 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
336 let attempt_clone = attempt.clone();
337
338 let result: crate::error::Result<()> = with_retry(&policy, || {
339 let attempt = attempt_clone.clone();
340 async move {
341 attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
342 Err(Error::Timeout {
343 timeout_seconds: 60,
344 })
345 }
346 })
347 .await;
348
349 assert!(result.is_err());
350 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 1);
352 }
353}