1use std::{fmt::Display, future::Future, marker::PhantomData, time::Duration};
19
20use serde::{Deserialize, Serialize};
21use tokio_util::sync::CancellationToken;
22
23use crate::{backoff::ExponentialBackoff, dst};
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct RetryConfig {
29 pub max_retries: u32,
31 pub initial_delay_ms: u64,
33 pub max_delay_ms: u64,
35 pub backoff_factor: f64,
37 pub jitter_ms: u64,
39 pub operation_timeout_ms: Option<u64>,
41 pub immediate_first: bool,
46 pub max_elapsed_ms: Option<u64>,
49}
50
51impl Default for RetryConfig {
52 fn default() -> Self {
53 Self {
54 max_retries: 3,
55 initial_delay_ms: 1_000,
56 max_delay_ms: 10_000,
57 backoff_factor: 2.0,
58 jitter_ms: 100,
59 operation_timeout_ms: Some(30_000),
60 immediate_first: false,
61 max_elapsed_ms: None,
62 }
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum RetryError {
72 Canceled,
74 OperationTimeout {
76 timeout_ms: u64,
78 },
79 ElapsedBudgetExceeded {
81 attempt: u32,
83 max_attempts: u32,
85 last_error: Option<String>,
87 },
88 InvalidConfiguration {
90 message: String,
92 },
93}
94
95impl Display for RetryError {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 Self::Canceled => write!(f, "canceled"),
99 Self::OperationTimeout { timeout_ms } => {
100 write!(f, "Timed out after {timeout_ms}ms")
101 }
102 Self::ElapsedBudgetExceeded {
103 attempt,
104 max_attempts,
105 last_error,
106 } => {
107 write!(f, "Retry budget exceeded ({attempt}/{max_attempts})")?;
108 if let Some(last_error) = last_error {
109 write!(f, ": last error: {last_error}")?;
110 }
111 Ok(())
112 }
113 Self::InvalidConfiguration { message } => {
114 write!(f, "Invalid configuration: {message}")
115 }
116 }
117 }
118}
119
120impl std::error::Error for RetryError {}
121
122#[derive(Clone, Debug)]
126pub struct RetryManager<E> {
127 config: RetryConfig,
128 _phantom: PhantomData<E>,
129}
130
131#[bon::bon]
132impl<E> RetryManager<E>
133where
134 E: std::error::Error,
135{
136 #[must_use]
138 pub const fn new(config: RetryConfig) -> Self {
139 Self {
140 config,
141 _phantom: PhantomData,
142 }
143 }
144
145 #[inline(always)]
147 fn budget_exceeded_error(&self, attempt: u32, last_error: Option<String>) -> RetryError {
148 RetryError::ElapsedBudgetExceeded {
149 attempt: attempt.saturating_add(1),
150 max_attempts: self.config.max_retries.saturating_add(1),
151 last_error,
152 }
153 }
154
155 #[expect(
182 clippy::type_complexity,
183 reason = "bon needs one concrete optional callback type for omitted retry delays"
184 )]
185 #[builder(finish_fn = execute)]
186 pub async fn invocation<F, Fut, T>(
187 &self,
188 #[builder(start_fn)] operation_name: &str,
189 #[builder(start_fn)] operation: F,
190 #[builder(start_fn)] should_retry: impl Fn(&E) -> bool,
191 #[builder(start_fn)] create_error: impl Fn(RetryError) -> E,
192 retry_delay: Option<&(dyn Fn(&E) -> Option<Duration> + Sync)>,
193 cancellation_token: Option<&CancellationToken>,
194 ) -> Result<T, E>
195 where
196 F: FnMut() -> Fut,
197 Fut: Future<Output = Result<T, E>>,
198 {
199 self.execute_retry_loop(
200 operation_name,
201 operation,
202 should_retry,
203 |e| retry_delay.and_then(|retry_delay| retry_delay(e)),
204 create_error,
205 cancellation_token,
206 )
207 .await
208 }
209
210 async fn execute_retry_loop<F, Fut, T>(
211 &self,
212 operation_name: &str,
213 mut operation: F,
214 should_retry: impl Fn(&E) -> bool,
215 retry_delay: impl Fn(&E) -> Option<Duration>,
216 create_error: impl Fn(RetryError) -> E,
217 cancellation_token: Option<&CancellationToken>,
218 ) -> Result<T, E>
219 where
220 F: FnMut() -> Fut,
221 Fut: Future<Output = Result<T, E>>,
222 {
223 let mut backoff = ExponentialBackoff::new(
224 Duration::from_millis(self.config.initial_delay_ms),
225 Duration::from_millis(self.config.max_delay_ms),
226 self.config.backoff_factor,
227 self.config.jitter_ms,
228 self.config.immediate_first,
229 )
230 .map_err(|e| {
231 create_error(RetryError::InvalidConfiguration {
232 message: e.to_string(),
233 })
234 })?;
235
236 let mut attempt = 0;
237 let start_time = dst::time::Instant::now();
238 let max_elapsed = self.config.max_elapsed_ms.map(Duration::from_millis);
239 let deadline = max_elapsed.and_then(|duration| start_time.checked_add(duration));
240 let mut last_delayed_error = None;
241
242 loop {
243 if let Some(token) = cancellation_token
244 && token.is_cancelled()
245 {
246 log::debug!("Operation '{operation_name}' canceled after {attempt} attempts");
247 return Err(create_error(RetryError::Canceled));
248 }
249
250 if let Some(max_elapsed) = max_elapsed {
251 let elapsed = start_time.elapsed();
252 if elapsed >= max_elapsed {
253 if let Some(e) = last_delayed_error {
254 return Err(e);
255 }
256 return Err(create_error(self.budget_exceeded_error(attempt, None)));
257 }
258 }
259 last_delayed_error = None;
260
261 let attempt_future = async {
262 let result = match (self.config.operation_timeout_ms, cancellation_token) {
263 (Some(timeout_ms), Some(token)) => {
264 tokio::select! {
265 biased;
266 result = dst::time::timeout(Duration::from_millis(timeout_ms), operation()) => result,
267 () = token.cancelled() => {
268 log::debug!("Operation '{operation_name}' canceled during execution");
269 return Err(create_error(RetryError::Canceled));
270 }
271 }
272 }
273 (Some(timeout_ms), None) => {
274 dst::time::timeout(Duration::from_millis(timeout_ms), operation()).await
275 }
276 (None, Some(token)) => tokio::select! {
277 biased;
278 result = operation() => Ok(result),
279 () = token.cancelled() => {
280 log::debug!("Operation '{operation_name}' canceled during execution");
281 return Err(create_error(RetryError::Canceled));
282 }
283 },
284 (None, None) => Ok(operation().await),
285 };
286 Ok(result)
287 };
288 let result = if let Some(deadline) = deadline {
289 tokio::select! {
290 biased;
291 () = dst::time::sleep_until(deadline) => {
292 if cancellation_token.is_some_and(CancellationToken::is_cancelled) {
293 log::debug!("Operation '{operation_name}' canceled during execution");
294 return Err(create_error(RetryError::Canceled));
295 }
296 return Err(create_error(self.budget_exceeded_error(attempt, None)));
297 }
298 result = attempt_future => result,
299 }
300 } else {
301 attempt_future.await
302 }?;
303
304 let (e, minimum_delay, timed_out) = match result {
305 Ok(Ok(success)) => {
306 if attempt > 0 {
307 log::trace!(
308 "Operation '{operation_name}' succeeded after {} attempts",
309 attempt + 1
310 );
311 }
312 return Ok(success);
313 }
314 Ok(Err(e)) => {
315 let minimum_delay = retry_delay(&e);
316 (e, minimum_delay, false)
317 }
318 Err(_) => (
319 create_error(RetryError::OperationTimeout {
320 timeout_ms: self.config.operation_timeout_ms.unwrap_or(0),
321 }),
322 None,
323 true,
324 ),
325 };
326
327 if !should_retry(&e) {
328 if timed_out {
329 log::trace!("Operation '{operation_name}' non-retryable timeout: {e}");
330 } else {
331 log::trace!("Operation '{operation_name}' non-retryable error: {e}");
332 }
333 return Err(e);
334 }
335
336 if attempt >= self.config.max_retries {
337 if timed_out {
338 log::trace!(
339 "Operation '{operation_name}' retries exhausted after timeout ({} attempts): {e}",
340 attempt + 1
341 );
342 } else {
343 log::trace!(
344 "Operation '{operation_name}' retries exhausted after {} attempts: {e}",
345 attempt + 1
346 );
347 }
348 return Err(e);
349 }
350
351 let mut delay = backoff.next_duration();
352
353 if let Some(minimum_delay) = minimum_delay {
354 delay = delay.max(minimum_delay);
355 }
356
357 if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
358 let elapsed = start_time.elapsed();
359 let remaining = Duration::from_millis(max_elapsed_ms).saturating_sub(elapsed);
360
361 if remaining.is_zero() {
362 if minimum_delay.is_some() {
363 return Err(e);
364 }
365 return Err(create_error(
366 self.budget_exceeded_error(attempt, Some(e.to_string())),
367 ));
368 }
369
370 if minimum_delay.is_some() && delay >= remaining {
371 return Err(e);
372 }
373 delay = delay.min(remaining);
374 }
375
376 debug_assert!(
377 minimum_delay.is_none_or(|minimum_delay| delay >= minimum_delay),
378 "retry delay must honor the error-provided minimum"
379 );
380
381 if timed_out {
382 log::trace!(
383 "Operation '{operation_name}' attempt {} timed out, retrying in {}ms: {e}",
384 attempt + 1,
385 delay.as_millis()
386 );
387 } else {
388 log::trace!(
389 "Operation '{operation_name}' attempt {} failed, retrying in {}ms: {e}",
390 attempt + 1,
391 delay.as_millis()
392 );
393 }
394
395 if delay.is_zero() {
397 tokio::task::yield_now().await;
398
399 if minimum_delay.is_some() {
400 last_delayed_error = Some(e);
401 }
402 attempt += 1;
403 continue;
404 }
405
406 if let Some(token) = cancellation_token {
407 tokio::select! {
408 biased;
409 () = dst::time::sleep(delay) => {},
410 () = token.cancelled() => {
411 log::debug!("Operation '{operation_name}' canceled during retry delay (attempt {})", attempt + 1);
412 return Err(create_error(RetryError::Canceled));
413 }
414 }
415 } else {
416 dst::time::sleep(delay).await;
417 }
418
419 if minimum_delay.is_some() {
420 last_delayed_error = Some(e);
421 }
422
423 attempt += 1;
424 }
425 }
426}
427
428#[must_use]
430pub fn create_default_retry_manager<E>() -> RetryManager<E>
431where
432 E: std::error::Error,
433{
434 RetryManager::new(RetryConfig::default())
435}
436
437#[must_use]
439pub const fn create_http_retry_manager<E>() -> RetryManager<E>
440where
441 E: std::error::Error,
442{
443 let config = RetryConfig {
444 max_retries: 3,
445 initial_delay_ms: 1_000,
446 max_delay_ms: 10_000,
447 backoff_factor: 2.0,
448 jitter_ms: 1_000,
449 operation_timeout_ms: Some(60_000), immediate_first: false,
451 max_elapsed_ms: Some(180_000), };
453 RetryManager::new(config)
454}
455
456#[must_use]
458pub const fn create_websocket_retry_manager<E>() -> RetryManager<E>
459where
460 E: std::error::Error,
461{
462 let config = RetryConfig {
463 max_retries: 5,
464 initial_delay_ms: 1_000,
465 max_delay_ms: 10_000,
466 backoff_factor: 2.0,
467 jitter_ms: 1_000,
468 operation_timeout_ms: Some(30_000), immediate_first: true,
470 max_elapsed_ms: Some(120_000), };
472 RetryManager::new(config)
473}
474
475#[cfg(test)]
476mod test_utils {
477 use super::RetryError;
478
479 #[derive(Debug, thiserror::Error)]
480 pub(super) enum TestError {
481 #[error("Retryable error: {0}")]
482 Retryable(String),
483 #[error("Non-retryable error: {0}")]
484 NonRetryable(String),
485 #[error("Timeout error: {0}")]
486 Timeout(RetryError),
487 }
488
489 pub(super) fn should_retry_test_error(error: &TestError) -> bool {
490 matches!(error, TestError::Retryable(_))
491 }
492
493 pub(super) fn create_test_error(error: RetryError) -> TestError {
494 TestError::Timeout(error)
495 }
496}
497
498#[cfg(test)]
507mod tests {
508 use std::sync::{
509 Arc,
510 atomic::{AtomicBool, AtomicU32, Ordering},
511 };
512
513 #[cfg(all(feature = "simulation", madsim))]
514 use madsim::task::{spawn, yield_now};
515 use rstest::rstest;
516 #[cfg(not(all(feature = "simulation", madsim)))]
517 use tokio::task::{spawn, yield_now};
518
519 use super::{test_utils::*, *};
520 use crate::dst::time;
521
522 const MAX_WAIT_ITERS: usize = 10_000;
523 const MAX_ADVANCE_ITERS: usize = 10_000;
524
525 #[cfg(all(feature = "simulation", madsim))]
526 pub(crate) async fn advance_clock(d: Duration) {
527 madsim::time::advance(d);
528 madsim::task::yield_now().await;
529 }
530
531 #[cfg(not(all(feature = "simulation", madsim)))]
532 pub(crate) async fn advance_clock(d: Duration) {
533 tokio::time::advance(d).await;
534 }
535
536 pub(crate) async fn yield_until<F>(mut condition: F)
537 where
538 F: FnMut() -> bool,
539 {
540 for _ in 0..MAX_WAIT_ITERS {
541 if condition() {
542 return;
543 }
544 yield_now().await;
545 }
546
547 panic!("yield_until timed out waiting for condition");
548 }
549
550 pub(crate) async fn advance_until<F>(mut condition: F)
551 where
552 F: FnMut() -> bool,
553 {
554 for _ in 0..MAX_ADVANCE_ITERS {
555 if condition() {
556 return;
557 }
558 advance_clock(Duration::from_millis(1)).await;
559 yield_now().await;
560 }
561
562 panic!("advance_until timed out waiting for condition");
563 }
564
565 #[rstest]
566 fn test_retry_config_default() {
567 let config = RetryConfig::default();
568 assert_eq!(config.max_retries, 3);
569 assert_eq!(config.initial_delay_ms, 1_000);
570 assert_eq!(config.max_delay_ms, 10_000);
571 #[allow(clippy::float_cmp, reason = "test asserts the default backoff factor")]
573 {
574 assert_eq!(config.backoff_factor, 2.0);
575 }
576 assert_eq!(config.jitter_ms, 100);
577 assert_eq!(config.operation_timeout_ms, Some(30_000));
578 assert!(!config.immediate_first);
579 assert_eq!(config.max_elapsed_ms, None);
580 }
581
582 #[rstest]
583 #[case::canceled(RetryError::Canceled, "canceled")]
584 #[case::operation_timeout(
585 RetryError::OperationTimeout { timeout_ms: 250 },
586 "Timed out after 250ms"
587 )]
588 #[case::elapsed_budget(
589 RetryError::ElapsedBudgetExceeded {
590 attempt: 2,
591 max_attempts: 4,
592 last_error: None,
593 },
594 "Retry budget exceeded (2/4)"
595 )]
596 #[case::elapsed_budget_with_last_error(
597 RetryError::ElapsedBudgetExceeded {
598 attempt: 3,
599 max_attempts: 5,
600 last_error: Some("network unavailable".to_string()),
601 },
602 "Retry budget exceeded (3/5): last error: network unavailable"
603 )]
604 #[case::invalid_configuration(
605 RetryError::InvalidConfiguration {
606 message: "delay_initial must be non-zero".to_string(),
607 },
608 "Invalid configuration: delay_initial must be non-zero"
609 )]
610 fn test_retry_error_display(#[case] error: RetryError, #[case] expected: &str) {
611 assert_eq!(error.to_string(), expected);
612 }
613
614 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
615 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
616 async fn test_invalid_configuration_reason() {
617 let manager = RetryManager::new(RetryConfig {
618 initial_delay_ms: 0,
619 ..RetryConfig::default()
620 });
621
622 let error = manager
623 .invocation(
624 "test_invalid_configuration",
625 || async { Ok::<i32, TestError>(42) },
626 should_retry_test_error,
627 create_test_error,
628 )
629 .execute()
630 .await
631 .unwrap_err();
632
633 let TestError::Timeout(reason) = error else {
634 panic!("expected invalid configuration, was {error}");
635 };
636 assert_eq!(
637 reason,
638 RetryError::InvalidConfiguration {
639 message: "delay_initial must be non-zero".to_string(),
640 }
641 );
642 }
643
644 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
645 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
646 async fn test_retry_manager_success_first_attempt() {
647 let manager = RetryManager::new(RetryConfig::default());
648
649 let result = manager
650 .invocation(
651 "test_operation",
652 || async { Ok::<i32, TestError>(42) },
653 should_retry_test_error,
654 create_test_error,
655 )
656 .execute()
657 .await;
658
659 assert_eq!(result.unwrap(), 42);
660 }
661
662 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
663 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
664 async fn test_retry_manager_non_retryable_error() {
665 let manager = RetryManager::new(RetryConfig::default());
666
667 let result = manager
668 .invocation(
669 "test_operation",
670 || async { Err::<i32, TestError>(TestError::NonRetryable("test".to_string())) },
671 should_retry_test_error,
672 create_test_error,
673 )
674 .execute()
675 .await;
676
677 assert!(result.is_err());
678 assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
679 }
680
681 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
682 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
683 async fn test_retry_manager_retryable_error_exhausted() {
684 let config = RetryConfig {
685 max_retries: 2,
686 initial_delay_ms: 10,
687 max_delay_ms: 50,
688 backoff_factor: 2.0,
689 jitter_ms: 0,
690 operation_timeout_ms: None,
691 immediate_first: false,
692 max_elapsed_ms: None,
693 };
694 let manager = RetryManager::new(config);
695
696 let result = manager
697 .invocation(
698 "test_operation",
699 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
700 should_retry_test_error,
701 create_test_error,
702 )
703 .execute()
704 .await;
705
706 assert!(result.is_err());
707 assert!(matches!(result.unwrap_err(), TestError::Retryable(_)));
708 }
709
710 #[rstest]
711 #[cfg_attr(
712 not(all(feature = "simulation", madsim)),
713 tokio::test(start_paused = true)
714 )]
715 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
716 async fn test_error_retry_delay_runs_outside_operation_timeout() {
717 let config = RetryConfig {
718 max_retries: 1,
719 initial_delay_ms: 10,
720 max_delay_ms: 10,
721 backoff_factor: 1.0,
722 jitter_ms: 0,
723 operation_timeout_ms: Some(50),
724 immediate_first: false,
725 max_elapsed_ms: Some(500),
726 };
727 let manager = RetryManager::new(config);
728 let attempts = Arc::new(AtomicU32::new(0));
729 let attempts_clone = attempts.clone();
730 let start = time::Instant::now();
731
732 let result = manager
733 .invocation(
734 "test_error_delay",
735 move || {
736 let attempts = attempts_clone.clone();
737 async move {
738 if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
739 Err(TestError::Retryable("rate limit".to_string()))
740 } else {
741 Ok(42)
742 }
743 }
744 },
745 should_retry_test_error,
746 create_test_error,
747 )
748 .retry_delay(&|_| Some(Duration::from_millis(200)))
749 .execute()
750 .await;
751
752 assert_eq!(result.unwrap(), 42);
753 assert_eq!(attempts.load(Ordering::SeqCst), 2);
754 #[cfg(not(all(feature = "simulation", madsim)))]
755 assert_eq!(start.elapsed(), Duration::from_millis(200));
756 #[cfg(all(feature = "simulation", madsim))]
757 assert!(
758 start.elapsed() >= Duration::from_millis(200)
759 && start.elapsed() < Duration::from_millis(201)
760 );
761 }
762
763 #[rstest]
764 #[cfg_attr(
765 not(all(feature = "simulation", madsim)),
766 tokio::test(start_paused = true)
767 )]
768 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
769 async fn test_error_retry_delay_observes_cancellation() {
770 let config = RetryConfig {
771 max_retries: 1,
772 initial_delay_ms: 10,
773 max_delay_ms: 10,
774 backoff_factor: 1.0,
775 jitter_ms: 0,
776 operation_timeout_ms: Some(50),
777 immediate_first: false,
778 max_elapsed_ms: Some(500),
779 };
780 let manager = RetryManager::new(config);
781 let attempts = Arc::new(AtomicU32::new(0));
782 let attempts_clone = attempts.clone();
783 let token = CancellationToken::new();
784 let cancel = token.clone();
785
786 spawn(async move {
787 time::sleep(Duration::from_millis(100)).await;
788 cancel.cancel();
789 });
790
791 let error = manager
792 .invocation(
793 "test_error_delay_cancellation",
794 move || {
795 let attempts = attempts_clone.clone();
796 async move {
797 attempts.fetch_add(1, Ordering::SeqCst);
798 Err::<i32, TestError>(TestError::Retryable("rate limit".to_string()))
799 }
800 },
801 should_retry_test_error,
802 create_test_error,
803 )
804 .retry_delay(&|_| Some(Duration::from_millis(200)))
805 .cancellation_token(&token)
806 .execute()
807 .await
808 .unwrap_err();
809
810 assert!(matches!(error, TestError::Timeout(RetryError::Canceled)));
811 assert_eq!(attempts.load(Ordering::SeqCst), 1);
812 }
813
814 #[rstest]
815 #[cfg_attr(
816 not(all(feature = "simulation", madsim)),
817 tokio::test(start_paused = true)
818 )]
819 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
820 async fn test_error_retry_delay_over_budget_returns_original_error() {
821 let config = RetryConfig {
822 max_retries: 3,
823 initial_delay_ms: 10,
824 max_delay_ms: 10,
825 backoff_factor: 1.0,
826 jitter_ms: 0,
827 operation_timeout_ms: Some(50),
828 immediate_first: false,
829 max_elapsed_ms: Some(100),
830 };
831 let manager = RetryManager::new(config);
832 let attempts = Arc::new(AtomicU32::new(0));
833 let attempts_clone = attempts.clone();
834
835 let error = manager
836 .invocation(
837 "test_error_delay_budget",
838 move || {
839 let attempts = attempts_clone.clone();
840 async move {
841 attempts.fetch_add(1, Ordering::SeqCst);
842 Err::<i32, TestError>(TestError::Retryable("rate limit".to_string()))
843 }
844 },
845 should_retry_test_error,
846 create_test_error,
847 )
848 .retry_delay(&|_| Some(Duration::from_millis(200)))
849 .execute()
850 .await
851 .unwrap_err();
852
853 let TestError::Retryable(message) = error else {
854 panic!("expected original retryable error, was {error}");
855 };
856 assert_eq!(message, "rate limit");
857 assert_eq!(attempts.load(Ordering::SeqCst), 1);
858 }
859
860 #[rstest]
861 #[cfg_attr(
862 not(all(feature = "simulation", madsim)),
863 tokio::test(start_paused = true)
864 )]
865 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
866 async fn test_error_retry_delay_overshoot_returns_original_error() {
867 let config = RetryConfig {
868 max_retries: 3,
869 initial_delay_ms: 10,
870 max_delay_ms: 10,
871 backoff_factor: 1.0,
872 jitter_ms: 0,
873 operation_timeout_ms: Some(20),
874 immediate_first: false,
875 max_elapsed_ms: Some(100),
876 };
877 let manager = RetryManager::new(config);
878 let attempts = Arc::new(AtomicU32::new(0));
879 let attempts_clone = attempts.clone();
880 let attempts_wait = attempts.clone();
881
882 let handle = spawn(async move {
883 manager
884 .invocation(
885 "test_error_delay_overshoot",
886 move || {
887 let attempts = attempts_clone.clone();
888 async move {
889 attempts.fetch_add(1, Ordering::SeqCst);
890 Err::<i32, TestError>(TestError::Retryable("rate limit".to_string()))
891 }
892 },
893 should_retry_test_error,
894 create_test_error,
895 )
896 .retry_delay(&|_| Some(Duration::from_millis(50)))
897 .execute()
898 .await
899 });
900
901 yield_until(|| attempts_wait.load(Ordering::SeqCst) == 1).await;
902 advance_clock(Duration::from_millis(100)).await;
903
904 let error = handle.await.unwrap().unwrap_err();
905 let TestError::Retryable(message) = error else {
906 panic!("expected original retryable error, was {error}");
907 };
908 assert_eq!(message, "rate limit");
909 assert_eq!(attempts.load(Ordering::SeqCst), 1);
910 }
911
912 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
913 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
914 async fn test_timeout_path() {
915 let config = RetryConfig {
916 max_retries: 2,
917 initial_delay_ms: 10,
918 max_delay_ms: 50,
919 backoff_factor: 2.0,
920 jitter_ms: 0,
921 operation_timeout_ms: Some(50),
922 immediate_first: false,
923 max_elapsed_ms: None,
924 };
925 let manager = RetryManager::new(config);
926
927 let result = manager
928 .invocation(
929 "test_timeout",
930 || async {
931 time::sleep(Duration::from_millis(100)).await;
932 Ok::<i32, TestError>(42)
933 },
934 should_retry_test_error,
935 create_test_error,
936 )
937 .execute()
938 .await;
939
940 let TestError::Timeout(reason) = result.unwrap_err() else {
941 panic!("expected operation timeout");
942 };
943 assert_eq!(reason, RetryError::OperationTimeout { timeout_ms: 50 });
944 }
945
946 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
947 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
948 async fn test_max_elapsed_time_budget() {
949 let config = RetryConfig {
950 max_retries: 10,
951 initial_delay_ms: 50,
952 max_delay_ms: 100,
953 backoff_factor: 2.0,
954 jitter_ms: 0,
955 operation_timeout_ms: None,
956 immediate_first: false,
957 max_elapsed_ms: Some(200),
958 };
959 let manager = RetryManager::new(config);
960
961 let start = time::Instant::now();
962 let result = manager
963 .invocation(
964 "test_budget",
965 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
966 should_retry_test_error,
967 create_test_error,
968 )
969 .execute()
970 .await;
971
972 let elapsed = start.elapsed();
973 assert!(result.is_err());
974 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
975 assert!(elapsed.as_millis() >= 150);
976 assert!(elapsed.as_millis() < 1000);
977 }
978
979 #[rstest]
980 #[case::without_operation_timeout(None)]
981 #[case::at_operation_timeout(Some(100))]
982 #[cfg_attr(
983 not(all(feature = "simulation", madsim)),
984 tokio::test(start_paused = true)
985 )]
986 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
987 async fn test_max_elapsed_bounds_in_flight_attempt(#[case] operation_timeout_ms: Option<u64>) {
988 let config = RetryConfig {
989 max_retries: 3,
990 initial_delay_ms: 10,
991 max_delay_ms: 20,
992 backoff_factor: 1.0,
993 jitter_ms: 0,
994 operation_timeout_ms,
995 immediate_first: false,
996 max_elapsed_ms: Some(100),
997 };
998 let manager = RetryManager::new(config);
999 let attempts = Arc::new(AtomicU32::new(0));
1000 let attempts_clone = Arc::clone(&attempts);
1001 let completed = Arc::new(AtomicBool::new(false));
1002 let completed_clone = Arc::clone(&completed);
1003 let start = time::Instant::now();
1004
1005 let error = manager
1006 .invocation(
1007 "test_in_flight_budget",
1008 move || {
1009 let attempts = Arc::clone(&attempts_clone);
1010 let completed = Arc::clone(&completed_clone);
1011 async move {
1012 attempts.fetch_add(1, Ordering::SeqCst);
1013 time::sleep(Duration::from_secs(1)).await;
1014 completed.store(true, Ordering::SeqCst);
1015 Ok::<i32, TestError>(42)
1016 }
1017 },
1018 should_retry_test_error,
1019 create_test_error,
1020 )
1021 .execute()
1022 .await
1023 .unwrap_err();
1024
1025 let TestError::Timeout(reason) = error else {
1026 panic!("expected retry budget timeout, was {error}");
1027 };
1028 assert_eq!(
1029 reason,
1030 RetryError::ElapsedBudgetExceeded {
1031 attempt: 1,
1032 max_attempts: 4,
1033 last_error: None,
1034 }
1035 );
1036 assert_eq!(attempts.load(Ordering::SeqCst), 1);
1037 assert!(!completed.load(Ordering::SeqCst));
1038 #[cfg(not(all(feature = "simulation", madsim)))]
1039 assert_eq!(start.elapsed(), Duration::from_millis(100));
1040 #[cfg(all(feature = "simulation", madsim))]
1041 assert!(
1042 start.elapsed() >= Duration::from_millis(100)
1043 && start.elapsed() < Duration::from_millis(101)
1044 );
1045 }
1046
1047 #[cfg_attr(
1048 not(all(feature = "simulation", madsim)),
1049 tokio::test(start_paused = true)
1050 )]
1051 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1052 async fn test_max_elapsed_bounds_later_in_flight_attempt() {
1053 let config = RetryConfig {
1054 max_retries: 3,
1055 initial_delay_ms: 10,
1056 max_delay_ms: 10,
1057 backoff_factor: 1.0,
1058 jitter_ms: 0,
1059 operation_timeout_ms: None,
1060 immediate_first: false,
1061 max_elapsed_ms: Some(100),
1062 };
1063 let manager = RetryManager::new(config);
1064 let attempts = Arc::new(AtomicU32::new(0));
1065 let attempts_clone = Arc::clone(&attempts);
1066
1067 let error = manager
1068 .invocation(
1069 "test_later_in_flight_budget",
1070 move || {
1071 let attempt = attempts_clone.fetch_add(1, Ordering::SeqCst);
1072 async move {
1073 if attempt == 0 {
1074 Err::<i32, TestError>(TestError::Retryable("first".to_string()))
1075 } else {
1076 std::future::pending().await
1077 }
1078 }
1079 },
1080 should_retry_test_error,
1081 create_test_error,
1082 )
1083 .execute()
1084 .await
1085 .unwrap_err();
1086
1087 let TestError::Timeout(reason) = error else {
1088 panic!("expected retry budget timeout, was {error}");
1089 };
1090 assert_eq!(
1091 reason,
1092 RetryError::ElapsedBudgetExceeded {
1093 attempt: 2,
1094 max_attempts: 4,
1095 last_error: None,
1096 }
1097 );
1098 assert_eq!(attempts.load(Ordering::SeqCst), 2);
1099 }
1100
1101 #[cfg_attr(
1102 not(all(feature = "simulation", madsim)),
1103 tokio::test(start_paused = true)
1104 )]
1105 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1106 async fn test_cancellation_takes_precedence_when_total_deadline_is_ready() {
1107 let config = RetryConfig {
1108 max_retries: 3,
1109 initial_delay_ms: 10,
1110 max_delay_ms: 10,
1111 backoff_factor: 1.0,
1112 jitter_ms: 0,
1113 operation_timeout_ms: None,
1114 immediate_first: false,
1115 max_elapsed_ms: Some(100),
1116 };
1117 let manager = RetryManager::new(config);
1118 let token = CancellationToken::new();
1119 let mut operation = Box::pin(
1120 manager
1121 .invocation(
1122 "test_cancellation_at_deadline",
1123 std::future::pending::<Result<i32, TestError>>,
1124 should_retry_test_error,
1125 create_test_error,
1126 )
1127 .cancellation_token(&token)
1128 .execute(),
1129 );
1130
1131 assert!(futures_util::poll!(&mut operation).is_pending());
1132 advance_clock(Duration::from_millis(100)).await;
1133 token.cancel();
1134
1135 let error = operation.await.unwrap_err();
1136 let TestError::Timeout(reason) = error else {
1137 panic!("expected cancellation timeout, was {error}");
1138 };
1139 assert_eq!(reason, RetryError::Canceled);
1140 }
1141
1142 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1143 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1144 async fn test_budget_exceeded_message_format() {
1145 let config = RetryConfig {
1146 max_retries: 5,
1147 initial_delay_ms: 10,
1148 max_delay_ms: 20,
1149 backoff_factor: 1.0,
1150 jitter_ms: 0,
1151 operation_timeout_ms: None,
1152 immediate_first: false,
1153 max_elapsed_ms: Some(35),
1154 };
1155 let manager = RetryManager::new(config);
1156
1157 let result = manager
1158 .invocation(
1159 "test_budget_msg",
1160 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
1161 should_retry_test_error,
1162 create_test_error,
1163 )
1164 .execute()
1165 .await;
1166
1167 assert!(result.is_err());
1168 let error_msg = result.unwrap_err().to_string();
1169
1170 assert!(error_msg.contains("Retry budget exceeded"));
1171 assert!(error_msg.contains("/6)"));
1172
1173 let prefix = "Timeout error: Retry budget exceeded (";
1174 let nums = error_msg
1175 .strip_circumfix(prefix, ")")
1176 .or_else(|| error_msg.strip_circumfix(prefix, "): last error: Retryable error: test"))
1177 .expect("error message should match retry budget format");
1178 let parts: Vec<&str> = nums.split('/').collect();
1179 assert_eq!(parts.len(), 2);
1180 let current: u32 = parts[0].parse().unwrap();
1181 let total: u32 = parts[1].parse().unwrap();
1182
1183 assert_eq!(total, 6, "Total should be max_retries + 1");
1184 assert!(current <= total, "Current attempt should not exceed total");
1185 assert!(current >= 1, "Current attempt should be at least 1");
1186 }
1187
1188 #[cfg_attr(
1189 not(all(feature = "simulation", madsim)),
1190 tokio::test(start_paused = true)
1191 )]
1192 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1193 async fn test_budget_exceeded_edge_cases() {
1194 let config = RetryConfig {
1195 max_retries: 2,
1196 initial_delay_ms: 50,
1197 max_delay_ms: 100,
1198 backoff_factor: 1.0,
1199 jitter_ms: 0,
1200 operation_timeout_ms: None,
1201 immediate_first: false,
1202 max_elapsed_ms: Some(100),
1203 };
1204 let manager = RetryManager::new(config);
1205
1206 let attempt_count = Arc::new(AtomicU32::new(0));
1207 let count_clone = attempt_count.clone();
1208
1209 let handle = spawn(async move {
1210 manager
1211 .invocation(
1212 "test_first_attempt",
1213 move || {
1214 let count = count_clone.clone();
1215 async move {
1216 count.fetch_add(1, Ordering::SeqCst);
1217 Err::<i32, TestError>(TestError::Retryable("test".to_string()))
1218 }
1219 },
1220 should_retry_test_error,
1221 create_test_error,
1222 )
1223 .execute()
1224 .await
1225 });
1226
1227 yield_until(|| attempt_count.load(Ordering::SeqCst) >= 1).await;
1229
1230 advance_clock(Duration::from_millis(101)).await;
1232 yield_now().await;
1233
1234 let result = handle.await.unwrap();
1235 assert!(result.is_err());
1236 let error_msg = result.unwrap_err().to_string();
1237
1238 assert!(
1240 error_msg.contains("(2/3)"),
1241 "Expected (2/3) but got: {error_msg}"
1242 );
1243 }
1244
1245 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1246 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1247 async fn test_budget_exceeded_no_overflow() {
1248 let config = RetryConfig {
1249 max_retries: u32::MAX,
1250 initial_delay_ms: 10,
1251 max_delay_ms: 20,
1252 backoff_factor: 1.0,
1253 jitter_ms: 0,
1254 operation_timeout_ms: None,
1255 immediate_first: false,
1256 max_elapsed_ms: Some(1),
1257 };
1258 let manager = RetryManager::new(config);
1259
1260 let result = manager
1261 .invocation(
1262 "test_overflow",
1263 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
1264 should_retry_test_error,
1265 create_test_error,
1266 )
1267 .execute()
1268 .await;
1269
1270 assert!(result.is_err());
1271 let error_msg = result.unwrap_err().to_string();
1272
1273 assert!(error_msg.contains("Retry budget exceeded"));
1275 assert!(error_msg.contains(&format!("/{}", u32::MAX)));
1276 }
1277
1278 #[rstest]
1279 fn test_http_retry_manager_config() {
1280 let manager = create_http_retry_manager::<TestError>();
1281 assert_eq!(manager.config.initial_delay_ms, 1_000);
1282 assert_eq!(manager.config.max_delay_ms, 10_000);
1283 #[allow(clippy::float_cmp, reason = "test asserts the preset backoff factor")]
1284 {
1285 assert_eq!(manager.config.backoff_factor, 2.0);
1286 }
1287 assert_eq!(manager.config.jitter_ms, 1_000);
1288 assert_eq!(manager.config.operation_timeout_ms, Some(60_000));
1289 assert_eq!(manager.config.max_retries, 3);
1290 assert!(!manager.config.immediate_first);
1291 assert_eq!(manager.config.max_elapsed_ms, Some(180_000));
1292 }
1293
1294 #[rstest]
1295 fn test_websocket_retry_manager_config() {
1296 let manager = create_websocket_retry_manager::<TestError>();
1297 assert_eq!(manager.config.initial_delay_ms, 1_000);
1298 assert_eq!(manager.config.max_delay_ms, 10_000);
1299 #[allow(clippy::float_cmp, reason = "test asserts the preset backoff factor")]
1300 {
1301 assert_eq!(manager.config.backoff_factor, 2.0);
1302 }
1303 assert_eq!(manager.config.jitter_ms, 1_000);
1304 assert_eq!(manager.config.operation_timeout_ms, Some(30_000));
1305 assert_eq!(manager.config.max_retries, 5);
1306 assert!(manager.config.immediate_first);
1307 assert_eq!(manager.config.max_elapsed_ms, Some(120_000));
1308 }
1309
1310 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1311 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1312 async fn test_timeout_respects_retry_predicate() {
1313 let config = RetryConfig {
1314 max_retries: 3,
1315 initial_delay_ms: 10,
1316 max_delay_ms: 50,
1317 backoff_factor: 2.0,
1318 jitter_ms: 0,
1319 operation_timeout_ms: Some(50),
1320 immediate_first: false,
1321 max_elapsed_ms: None,
1322 };
1323 let manager = RetryManager::new(config);
1324
1325 let should_not_retry_timeouts = |error: &TestError| !matches!(error, TestError::Timeout(_));
1327
1328 let result = manager
1329 .invocation(
1330 "test_timeout_non_retryable",
1331 || async {
1332 time::sleep(Duration::from_millis(100)).await;
1333 Ok::<i32, TestError>(42)
1334 },
1335 should_not_retry_timeouts,
1336 create_test_error,
1337 )
1338 .execute()
1339 .await;
1340
1341 assert!(result.is_err());
1343 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1344 }
1345
1346 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1347 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1348 async fn test_timeout_retries_when_predicate_allows() {
1349 let config = RetryConfig {
1350 max_retries: 2,
1351 initial_delay_ms: 10,
1352 max_delay_ms: 50,
1353 backoff_factor: 2.0,
1354 jitter_ms: 0,
1355 operation_timeout_ms: Some(50),
1356 immediate_first: false,
1357 max_elapsed_ms: None,
1358 };
1359 let manager = RetryManager::new(config);
1360
1361 let should_retry_timeouts = |error: &TestError| matches!(error, TestError::Timeout(_));
1363
1364 let start = time::Instant::now();
1365 let result = manager
1366 .invocation(
1367 "test_timeout_retryable",
1368 || async {
1369 time::sleep(Duration::from_millis(100)).await;
1370 Ok::<i32, TestError>(42)
1371 },
1372 should_retry_timeouts,
1373 create_test_error,
1374 )
1375 .execute()
1376 .await;
1377
1378 let elapsed = start.elapsed();
1379
1380 assert!(result.is_err());
1382 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1383 assert!(elapsed.as_millis() > 80); }
1386
1387 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1388 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1389 async fn test_successful_retry_after_failures() {
1390 let config = RetryConfig {
1391 max_retries: 3,
1392 initial_delay_ms: 10,
1393 max_delay_ms: 50,
1394 backoff_factor: 2.0,
1395 jitter_ms: 0,
1396 operation_timeout_ms: None,
1397 immediate_first: false,
1398 max_elapsed_ms: None,
1399 };
1400 let manager = RetryManager::new(config);
1401
1402 let attempt_counter = Arc::new(AtomicU32::new(0));
1403 let counter_clone = attempt_counter.clone();
1404
1405 let result = manager
1406 .invocation(
1407 "test_eventual_success",
1408 move || {
1409 let counter = counter_clone.clone();
1410 async move {
1411 let attempts = counter.fetch_add(1, Ordering::SeqCst);
1412 if attempts < 2 {
1413 Err(TestError::Retryable("temporary failure".to_string()))
1414 } else {
1415 Ok(42)
1416 }
1417 }
1418 },
1419 should_retry_test_error,
1420 create_test_error,
1421 )
1422 .execute()
1423 .await;
1424
1425 assert_eq!(result.unwrap(), 42);
1426 assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1427 }
1428
1429 #[cfg_attr(
1430 not(all(feature = "simulation", madsim)),
1431 tokio::test(start_paused = true)
1432 )]
1433 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1434 async fn test_immediate_first_retry() {
1435 let config = RetryConfig {
1436 max_retries: 2,
1437 initial_delay_ms: 100,
1438 max_delay_ms: 200,
1439 backoff_factor: 2.0,
1440 jitter_ms: 0,
1441 operation_timeout_ms: None,
1442 immediate_first: true,
1443 max_elapsed_ms: None,
1444 };
1445 let manager = RetryManager::new(config);
1446
1447 let attempt_times = Arc::new(parking_lot::Mutex::new(Vec::new()));
1448 let times_clone = attempt_times.clone();
1449 let start = time::Instant::now();
1450
1451 let handle = spawn({
1452 let times_clone = times_clone.clone();
1453 async move {
1454 let _ = manager
1455 .invocation(
1456 "test_immediate",
1457 move || {
1458 let times = times_clone.clone();
1459 async move {
1460 times.lock().push(start.elapsed());
1461 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1462 }
1463 },
1464 should_retry_test_error,
1465 create_test_error,
1466 )
1467 .execute()
1468 .await;
1469 }
1470 });
1471
1472 yield_until(|| attempt_times.lock().len() >= 2).await;
1474
1475 advance_clock(Duration::from_millis(100)).await;
1477 yield_now().await;
1478
1479 yield_until(|| attempt_times.lock().len() >= 3).await;
1481
1482 handle.await.unwrap();
1483
1484 let times = attempt_times.lock();
1485 assert_eq!(times.len(), 3); assert!(times[1] <= Duration::from_millis(1));
1489 assert!(times[2] >= Duration::from_millis(100));
1491 assert!(times[2] <= Duration::from_millis(110));
1492 }
1493
1494 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1495 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1496 async fn test_operation_without_timeout() {
1497 let config = RetryConfig {
1498 max_retries: 2,
1499 initial_delay_ms: 10,
1500 max_delay_ms: 50,
1501 backoff_factor: 2.0,
1502 jitter_ms: 0,
1503 operation_timeout_ms: None, immediate_first: false,
1505 max_elapsed_ms: None,
1506 };
1507 let manager = RetryManager::new(config);
1508
1509 let start = time::Instant::now();
1510 let result = manager
1511 .invocation(
1512 "test_no_timeout",
1513 || async {
1514 time::sleep(Duration::from_millis(50)).await;
1515 Ok::<i32, TestError>(42)
1516 },
1517 should_retry_test_error,
1518 create_test_error,
1519 )
1520 .execute()
1521 .await;
1522
1523 let elapsed = start.elapsed();
1524 assert_eq!(result.unwrap(), 42);
1525 assert!(elapsed.as_millis() >= 30);
1527 assert!(elapsed.as_millis() < 200);
1528 }
1529
1530 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1531 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1532 async fn test_zero_retries() {
1533 let config = RetryConfig {
1534 max_retries: 0,
1535 initial_delay_ms: 10,
1536 max_delay_ms: 50,
1537 backoff_factor: 2.0,
1538 jitter_ms: 0,
1539 operation_timeout_ms: None,
1540 immediate_first: false,
1541 max_elapsed_ms: None,
1542 };
1543 let manager = RetryManager::new(config);
1544
1545 let attempt_counter = Arc::new(AtomicU32::new(0));
1546 let counter_clone = attempt_counter.clone();
1547
1548 let result = manager
1549 .invocation(
1550 "test_no_retries",
1551 move || {
1552 let counter = counter_clone.clone();
1553 async move {
1554 counter.fetch_add(1, Ordering::SeqCst);
1555 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1556 }
1557 },
1558 should_retry_test_error,
1559 create_test_error,
1560 )
1561 .execute()
1562 .await;
1563
1564 assert!(result.is_err());
1565 assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
1567 }
1568
1569 #[cfg_attr(
1570 not(all(feature = "simulation", madsim)),
1571 tokio::test(start_paused = true)
1572 )]
1573 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1574 async fn test_jitter_applied() {
1575 let config = RetryConfig {
1576 max_retries: 2,
1577 initial_delay_ms: 50,
1578 max_delay_ms: 100,
1579 backoff_factor: 2.0,
1580 jitter_ms: 50, operation_timeout_ms: None,
1582 immediate_first: false,
1583 max_elapsed_ms: None,
1584 };
1585 let manager = RetryManager::new(config);
1586
1587 let delays = Arc::new(parking_lot::Mutex::new(Vec::new()));
1588 let delays_clone = delays.clone();
1589 let last_time = Arc::new(parking_lot::Mutex::new(time::Instant::now()));
1590 let last_time_clone = last_time.clone();
1591
1592 let handle = spawn({
1593 let delays_clone = delays_clone.clone();
1594 async move {
1595 let _ = manager
1596 .invocation(
1597 "test_jitter",
1598 move || {
1599 let delays = delays_clone.clone();
1600 let last_time = last_time_clone.clone();
1601 async move {
1602 let now = time::Instant::now();
1603 let delay = {
1604 let mut last = last_time.lock();
1605 let d = now.duration_since(*last);
1606 *last = now;
1607 d
1608 };
1609 delays.lock().push(delay);
1610 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1611 }
1612 },
1613 should_retry_test_error,
1614 create_test_error,
1615 )
1616 .execute()
1617 .await;
1618 }
1619 });
1620
1621 yield_until(|| !delays.lock().is_empty()).await;
1622 advance_until(|| delays.lock().len() >= 2).await;
1623 advance_until(|| delays.lock().len() >= 3).await;
1624
1625 handle.await.unwrap();
1626
1627 let delays = delays.lock();
1628 for delay in delays.iter().skip(1) {
1630 assert!(delay.as_millis() >= 50);
1632 assert!(delay.as_millis() <= 151);
1634 }
1635 }
1636
1637 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1638 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1639 async fn test_max_elapsed_stops_early() {
1640 let config = RetryConfig {
1641 max_retries: 100, initial_delay_ms: 50,
1643 max_delay_ms: 100,
1644 backoff_factor: 1.5,
1645 jitter_ms: 0,
1646 operation_timeout_ms: None,
1647 immediate_first: false,
1648 max_elapsed_ms: Some(150), };
1650 let manager = RetryManager::new(config);
1651
1652 let attempt_counter = Arc::new(AtomicU32::new(0));
1653 let counter_clone = attempt_counter.clone();
1654
1655 let start = time::Instant::now();
1656 let result = manager
1657 .invocation(
1658 "test_elapsed_limit",
1659 move || {
1660 let counter = counter_clone.clone();
1661 async move {
1662 counter.fetch_add(1, Ordering::SeqCst);
1663 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1664 }
1665 },
1666 should_retry_test_error,
1667 create_test_error,
1668 )
1669 .execute()
1670 .await;
1671
1672 let elapsed = start.elapsed();
1673 assert!(result.is_err());
1674 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1675
1676 let attempts = attempt_counter.load(Ordering::SeqCst);
1678 assert!(attempts < 10); assert!(elapsed.as_millis() >= 100);
1680 }
1681
1682 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1683 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1684 async fn test_mixed_errors_retry_behavior() {
1685 let config = RetryConfig {
1686 max_retries: 5,
1687 initial_delay_ms: 10,
1688 max_delay_ms: 50,
1689 backoff_factor: 2.0,
1690 jitter_ms: 0,
1691 operation_timeout_ms: None,
1692 immediate_first: false,
1693 max_elapsed_ms: None,
1694 };
1695 let manager = RetryManager::new(config);
1696
1697 let attempt_counter = Arc::new(AtomicU32::new(0));
1698 let counter_clone = attempt_counter.clone();
1699
1700 let result = manager
1701 .invocation(
1702 "test_mixed_errors",
1703 move || {
1704 let counter = counter_clone.clone();
1705 async move {
1706 let attempts = counter.fetch_add(1, Ordering::SeqCst);
1707 match attempts {
1708 0 => Err(TestError::Retryable("retry 1".to_string())),
1709 1 => Err(TestError::Retryable("retry 2".to_string())),
1710 2 => Err(TestError::NonRetryable("stop here".to_string())),
1711 _ => Ok(42),
1712 }
1713 }
1714 },
1715 should_retry_test_error,
1716 create_test_error,
1717 )
1718 .execute()
1719 .await;
1720
1721 assert!(result.is_err());
1722 assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
1723 assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1725 }
1726
1727 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1728 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1729 async fn test_cancellation_during_retry_delay() {
1730 use tokio_util::sync::CancellationToken;
1731
1732 let config = RetryConfig {
1733 max_retries: 10,
1734 initial_delay_ms: 500, max_delay_ms: 1000,
1736 backoff_factor: 2.0,
1737 jitter_ms: 0,
1738 operation_timeout_ms: None,
1739 immediate_first: false,
1740 max_elapsed_ms: None,
1741 };
1742 let manager = RetryManager::new(config);
1743
1744 let token = CancellationToken::new();
1745 let token_clone = token.clone();
1746
1747 spawn(async move {
1749 time::sleep(Duration::from_millis(100)).await;
1750 token_clone.cancel();
1751 });
1752
1753 let attempt_counter = Arc::new(AtomicU32::new(0));
1754 let counter_clone = attempt_counter.clone();
1755
1756 let start = time::Instant::now();
1757 let result = manager
1758 .invocation(
1759 "test_cancellation",
1760 move || {
1761 let counter = counter_clone.clone();
1762 async move {
1763 counter.fetch_add(1, Ordering::SeqCst);
1764 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1765 }
1766 },
1767 should_retry_test_error,
1768 create_test_error,
1769 )
1770 .cancellation_token(&token)
1771 .execute()
1772 .await;
1773
1774 let elapsed = start.elapsed();
1775
1776 assert!(result.is_err());
1778 let error_msg = format!("{}", result.unwrap_err());
1779 assert!(error_msg.contains("canceled"));
1780
1781 assert!(elapsed.as_millis() < 600);
1783
1784 let attempts = attempt_counter.load(Ordering::SeqCst);
1786 assert!(attempts >= 1);
1787 }
1788
1789 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1790 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1791 async fn test_cancellation_during_operation_execution() {
1792 use tokio_util::sync::CancellationToken;
1793
1794 let config = RetryConfig {
1795 max_retries: 5,
1796 initial_delay_ms: 50,
1797 max_delay_ms: 100,
1798 backoff_factor: 2.0,
1799 jitter_ms: 0,
1800 operation_timeout_ms: None,
1801 immediate_first: false,
1802 max_elapsed_ms: None,
1803 };
1804 let manager = RetryManager::new(config);
1805
1806 let token = CancellationToken::new();
1807 let token_clone = token.clone();
1808
1809 spawn(async move {
1811 time::sleep(Duration::from_millis(50)).await;
1812 token_clone.cancel();
1813 });
1814
1815 let start = time::Instant::now();
1816 let result = manager
1817 .invocation(
1818 "test_cancellation_during_op",
1819 || async {
1820 time::sleep(Duration::from_millis(200)).await;
1822 Ok::<i32, TestError>(42)
1823 },
1824 should_retry_test_error,
1825 create_test_error,
1826 )
1827 .cancellation_token(&token)
1828 .execute()
1829 .await;
1830
1831 let elapsed = start.elapsed();
1832
1833 assert!(result.is_err());
1835 let error_msg = format!("{}", result.unwrap_err());
1836 assert!(error_msg.contains("canceled"));
1837
1838 assert!(elapsed.as_millis() < 250);
1840 }
1841
1842 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1843 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1844 async fn test_cancellation_error_message() {
1845 use tokio_util::sync::CancellationToken;
1846
1847 let config = RetryConfig::default();
1848 let manager = RetryManager::new(config);
1849
1850 let token = CancellationToken::new();
1851 token.cancel(); let result = manager
1854 .invocation(
1855 "test_operation",
1856 || async { Ok::<i32, TestError>(42) },
1857 should_retry_test_error,
1858 create_test_error,
1859 )
1860 .cancellation_token(&token)
1861 .execute()
1862 .await;
1863
1864 assert!(result.is_err());
1865 let error_msg = format!("{}", result.unwrap_err());
1866 assert!(error_msg.contains("canceled"));
1867 }
1868}
1869
1870#[cfg(test)]
1871mod proptest_tests {
1872 use std::sync::{
1873 Arc,
1874 atomic::{AtomicU32, Ordering},
1875 };
1876
1877 #[cfg(all(feature = "simulation", madsim))]
1878 use madsim::task::spawn;
1879 use proptest::prelude::*;
1880 use rstest::rstest;
1882 #[cfg(not(all(feature = "simulation", madsim)))]
1883 use tokio::task::spawn;
1884
1885 #[cfg(not(all(feature = "simulation", madsim)))]
1886 use super::tests::{advance_until, yield_until};
1887 use super::{test_utils::*, tests::advance_clock, *};
1888 use crate::dst::time;
1889
1890 #[cfg(all(feature = "simulation", madsim))]
1895 fn build_paused_runtime() -> madsim::runtime::Runtime {
1896 madsim::runtime::Runtime::new()
1897 }
1898
1899 #[cfg(not(all(feature = "simulation", madsim)))]
1900 fn build_paused_runtime() -> tokio::runtime::Runtime {
1901 tokio::runtime::Builder::new_current_thread()
1902 .enable_time()
1903 .start_paused(true)
1904 .build()
1905 .unwrap()
1906 }
1907
1908 proptest! {
1909 #[rstest]
1910 fn test_retry_config_valid_ranges(
1911 max_retries in 0u32..100,
1912 initial_delay_ms in 1u64..10_000,
1913 max_delay_ms in 1u64..60_000,
1914 backoff_factor in 1.0f64..10.0,
1915 jitter_ms in 0u64..1_000,
1916 operation_timeout_ms in prop::option::of(1u64..120_000),
1917 immediate_first in any::<bool>(),
1918 max_elapsed_ms in prop::option::of(1u64..300_000)
1919 ) {
1920 let max_delay_ms = max_delay_ms.max(initial_delay_ms);
1922
1923 let config = RetryConfig {
1924 max_retries,
1925 initial_delay_ms,
1926 max_delay_ms,
1927 backoff_factor,
1928 jitter_ms,
1929 operation_timeout_ms,
1930 immediate_first,
1931 max_elapsed_ms,
1932 };
1933
1934 let _manager = RetryManager::<std::io::Error>::new(config);
1936 }
1937
1938 #[rstest]
1939 fn test_retry_attempts_bounded(
1940 max_retries in 0u32..5,
1941 initial_delay_ms in 1u64..10,
1942 backoff_factor in 1.0f64..2.0,
1943 ) {
1944 let rt = build_paused_runtime();
1945
1946 let config = RetryConfig {
1947 max_retries,
1948 initial_delay_ms,
1949 max_delay_ms: initial_delay_ms * 2,
1950 backoff_factor,
1951 jitter_ms: 0,
1952 operation_timeout_ms: None,
1953 immediate_first: false,
1954 max_elapsed_ms: None,
1955 };
1956
1957 let manager = RetryManager::new(config);
1958 let attempt_counter = Arc::new(AtomicU32::new(0));
1959 let counter_clone = attempt_counter.clone();
1960
1961 let _result = rt.block_on(manager.invocation(
1962 "prop_test",
1963 move || {
1964 let counter = counter_clone.clone();
1965 async move {
1966 counter.fetch_add(1, Ordering::SeqCst);
1967 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1968 }
1969 },
1970 |e: &TestError| matches!(e, TestError::Retryable(_)),
1971 TestError::Timeout,
1972 ).execute());
1973
1974 let attempts = attempt_counter.load(Ordering::SeqCst);
1975 prop_assert_eq!(attempts, max_retries + 1);
1977 }
1978
1979 #[rstest]
1980 fn test_error_retry_delay_obeys_selection_and_budget(
1981 backoff_ms in 1u64..500,
1982 minimum_ms in 0u64..1_000,
1983 operation_timeout_ms in 1u64..50,
1984 ) {
1985 let rt = build_paused_runtime();
1986 let selected_ms = backoff_ms.max(minimum_ms);
1987 let config = |max_elapsed_ms| RetryConfig {
1988 max_retries: 1,
1989 initial_delay_ms: backoff_ms,
1990 max_delay_ms: backoff_ms,
1991 backoff_factor: 1.0,
1992 jitter_ms: 0,
1993 operation_timeout_ms: Some(operation_timeout_ms),
1994 immediate_first: false,
1995 max_elapsed_ms: Some(max_elapsed_ms),
1996 };
1997 let minimum_delay = Duration::from_millis(minimum_ms);
1998
1999 let manager = RetryManager::new(config(selected_ms + 1));
2000 let attempts = Arc::new(AtomicU32::new(0));
2001 let attempts_clone = attempts.clone();
2002 let (result, elapsed) = rt.block_on(async {
2003 let start = time::Instant::now();
2004 let result = manager
2005 .invocation(
2006 "prop_error_delay_selection",
2007 move || {
2008 let attempts = attempts_clone.clone();
2009 async move {
2010 if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
2011 Err(TestError::Retryable("rate limit".to_string()))
2012 } else {
2013 Ok(42)
2014 }
2015 }
2016 },
2017 should_retry_test_error,
2018 create_test_error,
2019 )
2020 .retry_delay(&|_| Some(minimum_delay))
2021 .execute()
2022 .await;
2023 (result, start.elapsed())
2024 });
2025
2026 prop_assert_eq!(result.unwrap(), 42);
2027 prop_assert_eq!(attempts.load(Ordering::SeqCst), 2);
2028 let selected = Duration::from_millis(selected_ms);
2029 #[cfg(all(feature = "simulation", madsim))]
2030 {
2031 prop_assert!(elapsed >= selected);
2032 prop_assert!(elapsed < selected + Duration::from_millis(1));
2033 }
2034 #[cfg(not(all(feature = "simulation", madsim)))]
2035 prop_assert_eq!(elapsed, selected);
2036
2037 let manager = RetryManager::new(config(selected_ms));
2038 let attempts = Arc::new(AtomicU32::new(0));
2039 let attempts_clone = attempts.clone();
2040 let (error, elapsed) = rt.block_on(async {
2041 let start = time::Instant::now();
2042 let error = manager
2043 .invocation(
2044 "prop_error_delay_budget",
2045 move || {
2046 let attempts = attempts_clone.clone();
2047 async move {
2048 attempts.fetch_add(1, Ordering::SeqCst);
2049 Err::<i32, TestError>(TestError::Retryable(
2050 "rate limit".to_string(),
2051 ))
2052 }
2053 },
2054 should_retry_test_error,
2055 create_test_error,
2056 )
2057 .retry_delay(&|_| Some(minimum_delay))
2058 .execute()
2059 .await
2060 .unwrap_err();
2061 (error, start.elapsed())
2062 });
2063
2064 match error {
2065 TestError::Retryable(message) => prop_assert_eq!(message, "rate limit"),
2066 error => prop_assert!(false, "expected original retryable error, was {error}"),
2067 }
2068 prop_assert_eq!(attempts.load(Ordering::SeqCst), 1);
2069 prop_assert_eq!(elapsed, Duration::ZERO);
2070 }
2071
2072 #[rstest]
2073 fn test_timeout_always_respected(
2074 timeout_ms in 10u64..50,
2075 operation_delay_ms in 60u64..100,
2076 ) {
2077 let rt = build_paused_runtime();
2078
2079 let config = RetryConfig {
2080 max_retries: 0, initial_delay_ms: 10,
2082 max_delay_ms: 100,
2083 backoff_factor: 2.0,
2084 jitter_ms: 0,
2085 operation_timeout_ms: Some(timeout_ms),
2086 immediate_first: false,
2087 max_elapsed_ms: None,
2088 };
2089
2090 let manager = RetryManager::new(config);
2091
2092 let result = rt.block_on(async {
2093 let operation_future = manager.invocation(
2094 "timeout_test",
2095 move || async move {
2096 time::sleep(Duration::from_millis(operation_delay_ms)).await;
2097 Ok::<i32, TestError>(42)
2098 },
2099 |_: &TestError| true,
2100 TestError::Timeout,
2101 ).execute();
2102
2103 advance_clock(Duration::from_millis(timeout_ms + 10)).await;
2105 operation_future.await
2106 });
2107
2108 prop_assert!(result.is_err());
2110 prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
2111 }
2112
2113 #[rstest]
2114 fn test_max_elapsed_always_respected(
2115 max_elapsed_ms in 20u64..50,
2116 delay_per_retry in 15u64..30,
2117 max_retries in 10u32..20,
2118 ) {
2119 let rt = build_paused_runtime();
2120
2121 let config = RetryConfig {
2123 max_retries,
2124 initial_delay_ms: delay_per_retry,
2125 max_delay_ms: delay_per_retry * 2,
2126 backoff_factor: 1.0, jitter_ms: 0,
2128 operation_timeout_ms: None,
2129 immediate_first: false,
2130 max_elapsed_ms: Some(max_elapsed_ms),
2131 };
2132
2133 let manager = RetryManager::new(config);
2134 let attempt_counter = Arc::new(AtomicU32::new(0));
2135 let counter_clone = attempt_counter.clone();
2136
2137 let result = rt.block_on(async {
2138 let operation_future = manager.invocation(
2139 "elapsed_test",
2140 move || {
2141 let counter = counter_clone.clone();
2142 async move {
2143 counter.fetch_add(1, Ordering::SeqCst);
2144 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2145 }
2146 },
2147 |e: &TestError| matches!(e, TestError::Retryable(_)),
2148 TestError::Timeout,
2149 ).execute();
2150
2151 advance_clock(Duration::from_millis(max_elapsed_ms + delay_per_retry)).await;
2153 operation_future.await
2154 });
2155
2156 let attempts = attempt_counter.load(Ordering::SeqCst);
2157
2158 prop_assert!(result.is_err());
2160 prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
2161
2162 prop_assert!(attempts <= max_retries + 1);
2164 }
2165
2166 #[rstest]
2167 fn test_jitter_bounds(
2168 jitter_ms in 0u64..20,
2169 base_delay_ms in 10u64..30,
2170 ) {
2171 let rt = build_paused_runtime();
2172
2173 let config = RetryConfig {
2174 max_retries: 2,
2175 initial_delay_ms: base_delay_ms,
2176 max_delay_ms: base_delay_ms * 2,
2177 backoff_factor: 1.0, jitter_ms,
2179 operation_timeout_ms: None,
2180 immediate_first: false,
2181 max_elapsed_ms: None,
2182 };
2183
2184 let manager = RetryManager::new(config);
2185 let attempt_times = Arc::new(parking_lot::Mutex::new(Vec::new()));
2186 let attempt_times_for_block = attempt_times.clone();
2187
2188 rt.block_on(async move {
2189 #[cfg(not(all(feature = "simulation", madsim)))]
2190 let attempt_times_for_wait = attempt_times_for_block.clone();
2191 let handle = spawn({
2192 let attempt_times_for_task = attempt_times_for_block.clone();
2193 let manager = manager;
2194 async move {
2195 let start_time = time::Instant::now();
2196 let _ = manager
2197 .invocation(
2198 "jitter_test",
2199 move || {
2200 let attempt_times_inner = attempt_times_for_task.clone();
2201 async move {
2202 attempt_times_inner
2203 .lock()
2204 .push(start_time.elapsed());
2205 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2206 }
2207 },
2208 |e: &TestError| matches!(e, TestError::Retryable(_)),
2209 TestError::Timeout,
2210 ).execute()
2211 .await;
2212 }
2213 });
2214
2215 #[cfg(not(all(feature = "simulation", madsim)))]
2220 {
2221 yield_until(|| !attempt_times_for_wait.lock().is_empty()).await;
2222 advance_until(|| attempt_times_for_wait.lock().len() >= 2).await;
2223 advance_until(|| attempt_times_for_wait.lock().len() >= 3).await;
2224 }
2225
2226 handle.await.unwrap();
2227 });
2228
2229 let times = attempt_times.lock();
2230
2231 prop_assert!(times.len() >= 2);
2233
2234 prop_assert!(times[0].as_millis() < 5);
2236
2237 for i in 1..times.len() {
2239 let delay_from_previous = if i == 1 {
2240 times[i].checked_sub(times[0]).unwrap()
2241 } else {
2242 times[i].checked_sub(times[i - 1]).unwrap()
2243 };
2244
2245 let floor = base_delay_ms.min((base_delay_ms * 2).saturating_sub(jitter_ms));
2248 prop_assert!(
2249 delay_from_previous.as_millis() >= u128::from(floor),
2250 "Retry {} delay {}ms is less than floor {}ms",
2251 i, delay_from_previous.as_millis(), floor
2252 );
2253
2254 prop_assert!(
2256 delay_from_previous.as_millis() <= u128::from(base_delay_ms + jitter_ms + 1),
2257 "Retry {} delay {}ms exceeds base {} + jitter {}",
2258 i, delay_from_previous.as_millis(), base_delay_ms, jitter_ms
2259 );
2260 }
2261 }
2262
2263 #[rstest]
2264 fn test_immediate_first_property(
2265 immediate_first in any::<bool>(),
2266 initial_delay_ms in 10u64..30,
2267 ) {
2268 let rt = build_paused_runtime();
2269
2270 let config = RetryConfig {
2271 max_retries: 2,
2272 initial_delay_ms,
2273 max_delay_ms: initial_delay_ms * 2,
2274 backoff_factor: 2.0,
2275 jitter_ms: 0,
2276 operation_timeout_ms: None,
2277 immediate_first,
2278 max_elapsed_ms: None,
2279 };
2280
2281 let manager = RetryManager::new(config);
2282 let attempt_times = Arc::new(parking_lot::Mutex::new(Vec::new()));
2283 let attempt_times_for_block = attempt_times.clone();
2284
2285 rt.block_on(async move {
2286 #[cfg(not(all(feature = "simulation", madsim)))]
2287 let attempt_times_for_wait = attempt_times_for_block.clone();
2288 let handle = spawn({
2289 let attempt_times_for_task = attempt_times_for_block.clone();
2290 let manager = manager;
2291 async move {
2292 let start = time::Instant::now();
2293 let _ = manager
2294 .invocation(
2295 "immediate_test",
2296 move || {
2297 let attempt_times_inner = attempt_times_for_task.clone();
2298 async move {
2299 let elapsed = start.elapsed();
2300 attempt_times_inner.lock().push(elapsed);
2301 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2302 }
2303 },
2304 |e: &TestError| matches!(e, TestError::Retryable(_)),
2305 TestError::Timeout,
2306 ).execute()
2307 .await;
2308 }
2309 });
2310
2311 #[cfg(not(all(feature = "simulation", madsim)))]
2315 {
2316 yield_until(|| !attempt_times_for_wait.lock().is_empty()).await;
2317 advance_until(|| attempt_times_for_wait.lock().len() >= 2).await;
2318 advance_until(|| attempt_times_for_wait.lock().len() >= 3).await;
2319 }
2320
2321 handle.await.unwrap();
2322 });
2323
2324 let times = attempt_times.lock();
2325 prop_assert!(times.len() >= 2);
2326
2327 if immediate_first {
2328 prop_assert!(times[1].as_millis() < 20,
2330 "With immediate_first=true, first retry took {}ms",
2331 times[1].as_millis());
2332 } else {
2333 prop_assert!(times[1].as_millis() >= u128::from(initial_delay_ms - 1),
2335 "With immediate_first=false, first retry was too fast: {}ms",
2336 times[1].as_millis());
2337 }
2338 }
2339
2340 #[rstest]
2341 fn test_non_retryable_stops_immediately(
2342 attempt_before_non_retryable in 0usize..3,
2343 max_retries in 3u32..5,
2344 ) {
2345 let rt = build_paused_runtime();
2346
2347 let config = RetryConfig {
2348 max_retries,
2349 initial_delay_ms: 10,
2350 max_delay_ms: 100,
2351 backoff_factor: 2.0,
2352 jitter_ms: 0,
2353 operation_timeout_ms: None,
2354 immediate_first: false,
2355 max_elapsed_ms: None,
2356 };
2357
2358 let manager = RetryManager::new(config);
2359 let attempt_counter = Arc::new(AtomicU32::new(0));
2360 let counter_clone = attempt_counter.clone();
2361
2362 let result: Result<i32, TestError> = rt.block_on(manager.invocation(
2363 "non_retryable_test",
2364 move || {
2365 let counter = counter_clone.clone();
2366 async move {
2367 let attempts = counter.fetch_add(1, Ordering::SeqCst) as usize;
2368 if attempts == attempt_before_non_retryable {
2369 Err(TestError::NonRetryable("stop".to_string()))
2370 } else {
2371 Err(TestError::Retryable("retry".to_string()))
2372 }
2373 }
2374 },
2375 |e: &TestError| matches!(e, TestError::Retryable(_)),
2376 TestError::Timeout,
2377 ).execute());
2378
2379 let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
2380
2381 prop_assert!(result.is_err());
2382 prop_assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
2383 prop_assert_eq!(attempts, attempt_before_non_retryable + 1);
2385 }
2386
2387 #[rstest]
2388 fn test_cancellation_stops_immediately(
2389 cancel_after_ms in 10u64..100,
2390 initial_delay_ms in 200u64..500,
2391 ) {
2392 use tokio_util::sync::CancellationToken;
2393
2394 let rt = build_paused_runtime();
2395
2396 let config = RetryConfig {
2397 max_retries: 10,
2398 initial_delay_ms,
2399 max_delay_ms: initial_delay_ms * 2,
2400 backoff_factor: 2.0,
2401 jitter_ms: 0,
2402 operation_timeout_ms: None,
2403 immediate_first: false,
2404 max_elapsed_ms: None,
2405 };
2406
2407 let manager = RetryManager::new(config);
2408 let token = CancellationToken::new();
2409 let token_clone = token.clone();
2410
2411 let result: Result<i32, TestError> = rt.block_on(async {
2412 spawn(async move {
2414 time::sleep(Duration::from_millis(cancel_after_ms)).await;
2415 token_clone.cancel();
2416 });
2417
2418 let operation_future = manager.invocation(
2419 "cancellation_test",
2420 || async {
2421 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2422 },
2423 |e: &TestError| matches!(e, TestError::Retryable(_)),
2424 create_test_error,
2425 )
2426 .cancellation_token(&token)
2427 .execute();
2428
2429 advance_clock(Duration::from_millis(cancel_after_ms + 10)).await;
2431 operation_future.await
2432 });
2433
2434 prop_assert!(result.is_err());
2436 let error_msg = format!("{}", result.unwrap_err());
2437 prop_assert!(error_msg.contains("canceled"));
2438 }
2439
2440 #[rstest]
2441 fn test_budget_clamp_prevents_overshoot(
2442 max_elapsed_ms in 10u64..30,
2443 delay_per_retry in 30u64..50,
2444 ) {
2445 let rt = build_paused_runtime();
2446
2447 let config = RetryConfig {
2449 max_retries: 5,
2450 initial_delay_ms: delay_per_retry,
2451 max_delay_ms: delay_per_retry * 2,
2452 backoff_factor: 1.0,
2453 jitter_ms: 0,
2454 operation_timeout_ms: None,
2455 immediate_first: false,
2456 max_elapsed_ms: Some(max_elapsed_ms),
2457 };
2458
2459 let manager = RetryManager::new(config);
2460 let attempts = Arc::new(AtomicU32::new(0));
2461 let attempts_for_operation = Arc::clone(&attempts);
2462
2463 let (result, elapsed) = rt.block_on(async {
2464 let started_at = time::Instant::now();
2465 let result = manager.invocation(
2466 "budget_clamp_test",
2467 move || {
2468 let attempts = Arc::clone(&attempts_for_operation);
2469 async move {
2470 attempts.fetch_add(1, Ordering::SeqCst);
2471 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
2472 }
2473 },
2474 |e: &TestError| matches!(e, TestError::Retryable(_)),
2475 create_test_error,
2476 ).execute().await;
2477 (result, started_at.elapsed())
2478 });
2479
2480 assert!(matches!(
2481 result,
2482 Err(TestError::Timeout(RetryError::ElapsedBudgetExceeded {
2483 attempt: 2,
2484 max_attempts: 6,
2485 last_error: None,
2486 }))
2487 ));
2488 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2489 #[cfg(not(all(feature = "simulation", madsim)))]
2490 assert_eq!(elapsed, Duration::from_millis(max_elapsed_ms));
2491 #[cfg(all(feature = "simulation", madsim))]
2492 assert!(
2493 elapsed >= Duration::from_millis(max_elapsed_ms)
2494 && elapsed < Duration::from_millis(max_elapsed_ms + 1)
2495 );
2496 }
2497
2498 #[rstest]
2499 fn test_success_on_kth_attempt(
2500 k in 1usize..5,
2501 initial_delay_ms in 5u64..20,
2502 ) {
2503 let rt = build_paused_runtime();
2504
2505 let config = RetryConfig {
2506 max_retries: 10, initial_delay_ms,
2508 max_delay_ms: initial_delay_ms * 4,
2509 backoff_factor: 2.0,
2510 jitter_ms: 0,
2511 operation_timeout_ms: None,
2512 immediate_first: false,
2513 max_elapsed_ms: None,
2514 };
2515
2516 let manager = RetryManager::new(config);
2517 let attempt_counter = Arc::new(AtomicU32::new(0));
2518 let counter_clone = attempt_counter.clone();
2519 let target_k = k;
2520
2521 let (result, _elapsed) = rt.block_on(async {
2522 let start = time::Instant::now();
2523
2524 let operation_future = manager.invocation(
2525 "kth_attempt_test",
2526 move || {
2527 let counter = counter_clone.clone();
2528 async move {
2529 let attempt = counter.fetch_add(1, Ordering::SeqCst) as usize;
2530 if attempt + 1 == target_k {
2531 Ok(42)
2532 } else {
2533 Err(TestError::Retryable("retry".to_string()))
2534 }
2535 }
2536 },
2537 |e: &TestError| matches!(e, TestError::Retryable(_)),
2538 create_test_error,
2539 ).execute();
2540
2541 for _ in 0..k {
2543 advance_clock(Duration::from_millis(initial_delay_ms * 4)).await;
2544 }
2545
2546 let result = operation_future.await;
2547 let elapsed = start.elapsed();
2548
2549 (result, elapsed)
2550 });
2551
2552 let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
2553
2554 prop_assert!(result.is_ok());
2556 prop_assert_eq!(result.unwrap(), 42);
2557 prop_assert_eq!(attempts, k);
2558 }
2559 }
2560}