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