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 span = tracing::debug_span!(
160 "claude.retry",
161 max_attempts = policy.max_attempts,
162 attempts = tracing::field::Empty,
163 );
164 let _enter = span.enter();
165 let mut last_error = None;
166
167 for attempt in 0..policy.max_attempts {
168 span.record("attempts", attempt + 1);
169 match operation().await {
170 Ok(result) => return Ok(result),
171 Err(e) => {
172 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
173 let delay = policy.delay_for_attempt(attempt);
174 warn!(
175 attempt = attempt + 1,
176 max_attempts = policy.max_attempts,
177 delay_ms = delay.as_millis() as u64,
178 timeout = matches!(e, crate::Error::Timeout { .. }),
179 exit_code = match &e {
180 crate::Error::CommandFailed { exit_code, .. } => Some(*exit_code),
181 _ => None,
182 },
183 "retrying after transient error"
184 );
185 tokio::time::sleep(delay).await;
186 last_error = Some(e);
187 } else {
188 return Err(e);
189 }
190 }
191 }
192 }
193
194 Err(last_error.expect("at least one attempt was made"))
195}
196
197#[cfg(feature = "sync")]
200pub(crate) fn with_retry_sync<F, T>(
201 policy: &RetryPolicy,
202 mut operation: F,
203) -> crate::error::Result<T>
204where
205 F: FnMut() -> crate::error::Result<T>,
206{
207 let span = tracing::debug_span!(
208 "claude.retry",
209 max_attempts = policy.max_attempts,
210 attempts = tracing::field::Empty,
211 );
212 let _enter = span.enter();
213 let mut last_error = None;
214
215 for attempt in 0..policy.max_attempts {
216 span.record("attempts", attempt + 1);
217 match operation() {
218 Ok(result) => return Ok(result),
219 Err(e) => {
220 if attempt + 1 < policy.max_attempts && policy.should_retry(&e) {
221 let delay = policy.delay_for_attempt(attempt);
222 warn!(
223 attempt = attempt + 1,
224 max_attempts = policy.max_attempts,
225 delay_ms = delay.as_millis() as u64,
226 timeout = matches!(e, crate::Error::Timeout { .. }),
227 exit_code = match &e {
228 crate::Error::CommandFailed { exit_code, .. } => Some(*exit_code),
229 _ => None,
230 },
231 "retrying after transient error"
232 );
233 std::thread::sleep(delay);
234 last_error = Some(e);
235 } else {
236 return Err(e);
237 }
238 }
239 }
240 }
241
242 Err(last_error.expect("at least one attempt was made"))
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_default_policy() {
251 let policy = RetryPolicy::new();
252 assert_eq!(policy.max_attempts, 3);
253 assert_eq!(policy.initial_backoff, Duration::from_secs(1));
254 assert!(policy.retry_on_timeout);
255 assert!(policy.retry_exit_codes.is_empty());
256 }
257
258 #[test]
259 fn test_builder() {
260 let policy = RetryPolicy::new()
261 .max_attempts(5)
262 .initial_backoff(Duration::from_millis(500))
263 .exponential()
264 .retry_on_timeout(false)
265 .retry_on_exit_codes([1, 2, 3]);
266
267 assert_eq!(policy.max_attempts, 5);
268 assert_eq!(policy.initial_backoff, Duration::from_millis(500));
269 assert!(!policy.retry_on_timeout);
270 assert_eq!(policy.retry_exit_codes, vec![1, 2, 3]);
271 }
272
273 #[test]
274 fn test_fixed_delay() {
275 let policy = RetryPolicy::new()
276 .initial_backoff(Duration::from_secs(2))
277 .fixed();
278
279 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(2));
280 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
281 assert_eq!(policy.delay_for_attempt(5), Duration::from_secs(2));
282 }
283
284 #[test]
285 fn test_exponential_delay() {
286 let policy = RetryPolicy::new()
287 .initial_backoff(Duration::from_secs(1))
288 .max_backoff(Duration::from_secs(30))
289 .exponential();
290
291 assert_eq!(policy.delay_for_attempt(0), Duration::from_secs(1));
292 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
293 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(4));
294 assert_eq!(policy.delay_for_attempt(3), Duration::from_secs(8));
295 assert_eq!(policy.delay_for_attempt(10), Duration::from_secs(30));
297 }
298
299 #[test]
300 fn test_should_retry_timeout() {
301 let policy = RetryPolicy::new().retry_on_timeout(true);
302 let error = Error::Timeout {
303 timeout_seconds: 60,
304 };
305 assert!(policy.should_retry(&error));
306
307 let policy = RetryPolicy::new().retry_on_timeout(false);
308 assert!(!policy.should_retry(&error));
309 }
310
311 #[test]
312 fn test_should_retry_exit_code() {
313 let policy = RetryPolicy::new().retry_on_exit_codes([1, 2]);
314
315 let retryable = Error::CommandFailed {
316 command: "test".into(),
317 exit_code: 1,
318 stdout: String::new(),
319 stderr: String::new(),
320 working_dir: None,
321 };
322 assert!(policy.should_retry(&retryable));
323
324 let not_retryable = Error::CommandFailed {
325 command: "test".into(),
326 exit_code: 99,
327 stdout: String::new(),
328 stderr: String::new(),
329 working_dir: None,
330 };
331 assert!(!policy.should_retry(¬_retryable));
332 }
333
334 #[test]
335 fn test_should_not_retry_other_errors() {
336 let policy = RetryPolicy::new()
337 .retry_on_timeout(true)
338 .retry_on_exit_codes([1]);
339
340 let error = Error::NotFound;
341 assert!(!policy.should_retry(&error));
342 }
343
344 #[cfg(feature = "async")]
345 #[tokio::test]
346 async fn test_with_retry_succeeds_first_try() {
347 let policy = RetryPolicy::new().max_attempts(3);
348 let result = with_retry(&policy, || async { Ok::<_, Error>(42) }).await;
349 assert_eq!(result.unwrap(), 42);
350 }
351
352 #[cfg(feature = "async")]
353 #[tokio::test]
354 async fn test_with_retry_succeeds_after_failures() {
355 let policy = RetryPolicy::new()
356 .max_attempts(3)
357 .initial_backoff(Duration::from_millis(1))
358 .retry_on_timeout(true);
359
360 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
361 let attempt_clone = attempt.clone();
362
363 let result = with_retry(&policy, || {
364 let attempt = attempt_clone.clone();
365 async move {
366 let n = attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
367 if n < 2 {
368 Err(Error::Timeout {
369 timeout_seconds: 60,
370 })
371 } else {
372 Ok(42)
373 }
374 }
375 })
376 .await;
377
378 assert_eq!(result.unwrap(), 42);
379 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 3);
380 }
381
382 #[cfg(feature = "async")]
383 #[tokio::test]
384 async fn test_with_retry_exhausts_attempts() {
385 let policy = RetryPolicy::new()
386 .max_attempts(2)
387 .initial_backoff(Duration::from_millis(1))
388 .retry_on_timeout(true);
389
390 let result: crate::error::Result<()> = with_retry(&policy, || async {
391 Err(Error::Timeout {
392 timeout_seconds: 60,
393 })
394 })
395 .await;
396
397 assert!(matches!(result, Err(Error::Timeout { .. })));
398 }
399
400 #[cfg(feature = "async")]
401 #[tokio::test]
402 async fn test_with_retry_no_retry_on_non_retryable() {
403 let policy = RetryPolicy::new()
404 .max_attempts(3)
405 .initial_backoff(Duration::from_millis(1))
406 .retry_on_timeout(false);
407
408 let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
409 let attempt_clone = attempt.clone();
410
411 let result: crate::error::Result<()> = with_retry(&policy, || {
412 let attempt = attempt_clone.clone();
413 async move {
414 attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
415 Err(Error::Timeout {
416 timeout_seconds: 60,
417 })
418 }
419 })
420 .await;
421
422 assert!(result.is_err());
423 assert_eq!(attempt.load(std::sync::atomic::Ordering::SeqCst), 1);
425 }
426
427 #[cfg(feature = "sync")]
428 #[test]
429 fn test_with_retry_sync_succeeds_first_try() {
430 let policy = RetryPolicy::new().max_attempts(3);
431 let result = with_retry_sync(&policy, || Ok::<_, Error>(42));
432 assert_eq!(result.unwrap(), 42);
433 }
434
435 #[cfg(feature = "sync")]
436 #[test]
437 fn test_with_retry_sync_succeeds_after_failures() {
438 use std::sync::atomic::{AtomicU32, Ordering};
439
440 let policy = RetryPolicy::new()
441 .max_attempts(3)
442 .initial_backoff(Duration::from_millis(1))
443 .retry_on_timeout(true);
444
445 let attempt = AtomicU32::new(0);
446 let result = with_retry_sync(&policy, || {
447 let n = attempt.fetch_add(1, Ordering::SeqCst);
448 if n < 2 {
449 Err(Error::Timeout {
450 timeout_seconds: 60,
451 })
452 } else {
453 Ok(42)
454 }
455 });
456
457 assert_eq!(result.unwrap(), 42);
458 assert_eq!(attempt.load(Ordering::SeqCst), 3);
459 }
460
461 #[cfg(feature = "sync")]
462 #[test]
463 fn test_with_retry_sync_exhausts_attempts() {
464 let policy = RetryPolicy::new()
465 .max_attempts(2)
466 .initial_backoff(Duration::from_millis(1))
467 .retry_on_timeout(true);
468
469 let result: crate::error::Result<()> = with_retry_sync(&policy, || {
470 Err(Error::Timeout {
471 timeout_seconds: 60,
472 })
473 });
474
475 assert!(matches!(result, Err(Error::Timeout { .. })));
476 }
477
478 #[cfg(feature = "sync")]
479 #[test]
480 fn test_with_retry_sync_no_retry_on_non_retryable() {
481 use std::sync::atomic::{AtomicU32, Ordering};
482
483 let policy = RetryPolicy::new()
484 .max_attempts(3)
485 .initial_backoff(Duration::from_millis(1))
486 .retry_on_timeout(false);
487
488 let attempt = AtomicU32::new(0);
489 let result: crate::error::Result<()> = with_retry_sync(&policy, || {
490 attempt.fetch_add(1, Ordering::SeqCst);
491 Err(Error::Timeout {
492 timeout_seconds: 60,
493 })
494 });
495
496 assert!(result.is_err());
497 assert_eq!(attempt.load(Ordering::SeqCst), 1);
498 }
499}