1use std::{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>,
42 pub immediate_first: bool,
45 pub max_elapsed_ms: Option<u64>,
48}
49
50impl Default for RetryConfig {
51 fn default() -> Self {
52 Self {
53 max_retries: 3,
54 initial_delay_ms: 1_000,
55 max_delay_ms: 10_000,
56 backoff_factor: 2.0,
57 jitter_ms: 100,
58 operation_timeout_ms: Some(30_000),
59 immediate_first: false,
60 max_elapsed_ms: None,
61 }
62 }
63}
64
65#[derive(Clone, Debug)]
69pub struct RetryManager<E> {
70 config: RetryConfig,
71 _phantom: PhantomData<E>,
72}
73
74impl<E> RetryManager<E>
75where
76 E: std::error::Error,
77{
78 #[must_use]
80 pub const fn new(config: RetryConfig) -> Self {
81 Self {
82 config,
83 _phantom: PhantomData,
84 }
85 }
86
87 #[inline(always)]
89 fn budget_exceeded_msg(&self, attempt: u32) -> String {
90 format!(
91 "Retry budget exceeded ({}/{})",
92 attempt.saturating_add(1),
93 self.config.max_retries.saturating_add(1)
94 )
95 }
96
97 pub async fn execute_with_retry_inner<F, Fut, T>(
113 &self,
114 operation_name: &str,
115 mut operation: F,
116 should_retry: impl Fn(&E) -> bool,
117 create_error: impl Fn(String) -> E,
118 cancel: Option<&CancellationToken>,
119 ) -> Result<T, E>
120 where
121 F: FnMut() -> Fut,
122 Fut: Future<Output = Result<T, E>>,
123 {
124 let mut backoff = ExponentialBackoff::new(
125 Duration::from_millis(self.config.initial_delay_ms),
126 Duration::from_millis(self.config.max_delay_ms),
127 self.config.backoff_factor,
128 self.config.jitter_ms,
129 self.config.immediate_first,
130 )
131 .map_err(|e| create_error(format!("Invalid configuration: {e}")))?;
132
133 let mut attempt = 0;
134 let start_time = dst::time::Instant::now();
135
136 loop {
137 if let Some(token) = cancel
138 && token.is_cancelled()
139 {
140 log::debug!("Operation '{operation_name}' canceled after {attempt} attempts");
141 return Err(create_error("canceled".to_string()));
142 }
143
144 if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
145 let elapsed = start_time.elapsed();
146 if elapsed.as_millis() >= u128::from(max_elapsed_ms) {
147 return Err(create_error(self.budget_exceeded_msg(attempt)));
148 }
149 }
150
151 let result = match (self.config.operation_timeout_ms, cancel) {
152 (Some(timeout_ms), Some(token)) => {
153 tokio::select! {
154 biased;
155 result = dst::time::timeout(Duration::from_millis(timeout_ms), operation()) => result,
156 () = token.cancelled() => {
157 log::debug!("Operation '{operation_name}' canceled during execution");
158 return Err(create_error("canceled".to_string()));
159 }
160 }
161 }
162 (Some(timeout_ms), None) => {
163 dst::time::timeout(Duration::from_millis(timeout_ms), operation()).await
164 }
165 (None, Some(token)) => {
166 tokio::select! {
167 biased;
168 result = operation() => Ok(result),
169 () = token.cancelled() => {
170 log::debug!("Operation '{operation_name}' canceled during execution");
171 return Err(create_error("canceled".to_string()));
172 }
173 }
174 }
175 (None, None) => Ok(operation().await),
176 };
177
178 match result {
179 Ok(Ok(success)) => {
180 if attempt > 0 {
181 log::trace!(
182 "Operation '{operation_name}' succeeded after {} attempts",
183 attempt + 1
184 );
185 }
186 return Ok(success);
187 }
188 Ok(Err(e)) => {
189 if !should_retry(&e) {
190 log::trace!("Operation '{operation_name}' non-retryable error: {e}");
191 return Err(e);
192 }
193
194 if attempt >= self.config.max_retries {
195 log::trace!(
196 "Operation '{operation_name}' retries exhausted after {} attempts: {e}",
197 attempt + 1
198 );
199 return Err(e);
200 }
201
202 let mut delay = backoff.next_duration();
203
204 if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
205 let elapsed = start_time.elapsed();
206 let remaining =
207 Duration::from_millis(max_elapsed_ms).saturating_sub(elapsed);
208
209 if remaining.is_zero() {
210 return Err(create_error(format!(
211 "{}: last error: {e}",
212 self.budget_exceeded_msg(attempt)
213 )));
214 }
215
216 delay = delay.min(remaining);
217 }
218
219 log::trace!(
220 "Operation '{operation_name}' attempt {} failed, retrying in {}ms: {e}",
221 attempt + 1,
222 delay.as_millis()
223 );
224
225 if delay.is_zero() {
227 tokio::task::yield_now().await;
228 attempt += 1;
229 continue;
230 }
231
232 if let Some(token) = cancel {
233 tokio::select! {
234 biased;
235 () = dst::time::sleep(delay) => {},
236 () = token.cancelled() => {
237 log::debug!("Operation '{operation_name}' canceled during retry delay (attempt {})", attempt + 1);
238 return Err(create_error("canceled".to_string()));
239 }
240 }
241 } else {
242 dst::time::sleep(delay).await;
243 }
244 attempt += 1;
245 }
246 Err(_) => {
247 let e = create_error(format!(
248 "Timed out after {}ms",
249 self.config.operation_timeout_ms.unwrap_or(0)
250 ));
251
252 if !should_retry(&e) {
253 log::trace!("Operation '{operation_name}' non-retryable timeout: {e}");
254 return Err(e);
255 }
256
257 if attempt >= self.config.max_retries {
258 log::trace!(
259 "Operation '{operation_name}' retries exhausted after timeout ({} attempts): {e}",
260 attempt + 1
261 );
262 return Err(e);
263 }
264
265 let mut delay = backoff.next_duration();
266
267 if let Some(max_elapsed_ms) = self.config.max_elapsed_ms {
268 let elapsed = start_time.elapsed();
269 let remaining =
270 Duration::from_millis(max_elapsed_ms).saturating_sub(elapsed);
271
272 if remaining.is_zero() {
273 return Err(create_error(format!(
274 "{}: last error: {e}",
275 self.budget_exceeded_msg(attempt)
276 )));
277 }
278
279 delay = delay.min(remaining);
280 }
281
282 log::trace!(
283 "Operation '{operation_name}' attempt {} timed out, retrying in {}ms: {e}",
284 attempt + 1,
285 delay.as_millis()
286 );
287
288 if delay.is_zero() {
290 tokio::task::yield_now().await;
291 attempt += 1;
292 continue;
293 }
294
295 if let Some(token) = cancel {
296 tokio::select! {
297 biased;
298 () = dst::time::sleep(delay) => {},
299 () = token.cancelled() => {
300 log::debug!("Operation '{operation_name}' canceled during retry delay (attempt {})", attempt + 1);
301 return Err(create_error("canceled".to_string()));
302 }
303 }
304 } else {
305 dst::time::sleep(delay).await;
306 }
307 attempt += 1;
308 }
309 }
310 }
311 }
312
313 pub async fn execute_with_retry<F, Fut, T>(
320 &self,
321 operation_name: &str,
322 operation: F,
323 should_retry: impl Fn(&E) -> bool,
324 create_error: impl Fn(String) -> E,
325 ) -> Result<T, E>
326 where
327 F: FnMut() -> Fut,
328 Fut: Future<Output = Result<T, E>>,
329 {
330 self.execute_with_retry_inner(operation_name, operation, should_retry, create_error, None)
331 .await
332 }
333
334 pub async fn execute_with_retry_with_cancel<F, Fut, T>(
341 &self,
342 operation_name: &str,
343 operation: F,
344 should_retry: impl Fn(&E) -> bool,
345 create_error: impl Fn(String) -> E,
346 cancellation_token: &CancellationToken,
347 ) -> Result<T, E>
348 where
349 F: FnMut() -> Fut,
350 Fut: Future<Output = Result<T, E>>,
351 {
352 self.execute_with_retry_inner(
353 operation_name,
354 operation,
355 should_retry,
356 create_error,
357 Some(cancellation_token),
358 )
359 .await
360 }
361}
362
363#[must_use]
365pub fn create_default_retry_manager<E>() -> RetryManager<E>
366where
367 E: std::error::Error,
368{
369 RetryManager::new(RetryConfig::default())
370}
371
372#[must_use]
374pub const fn create_http_retry_manager<E>() -> RetryManager<E>
375where
376 E: std::error::Error,
377{
378 let config = RetryConfig {
379 max_retries: 3,
380 initial_delay_ms: 1_000,
381 max_delay_ms: 10_000,
382 backoff_factor: 2.0,
383 jitter_ms: 1_000,
384 operation_timeout_ms: Some(60_000), immediate_first: false,
386 max_elapsed_ms: Some(180_000), };
388 RetryManager::new(config)
389}
390
391#[must_use]
393pub const fn create_websocket_retry_manager<E>() -> RetryManager<E>
394where
395 E: std::error::Error,
396{
397 let config = RetryConfig {
398 max_retries: 5,
399 initial_delay_ms: 1_000,
400 max_delay_ms: 10_000,
401 backoff_factor: 2.0,
402 jitter_ms: 1_000,
403 operation_timeout_ms: Some(30_000), immediate_first: true,
405 max_elapsed_ms: Some(120_000), };
407 RetryManager::new(config)
408}
409
410#[cfg(test)]
411mod test_utils {
412 #[derive(Debug, thiserror::Error)]
413 pub(super) enum TestError {
414 #[error("Retryable error: {0}")]
415 Retryable(String),
416 #[error("Non-retryable error: {0}")]
417 NonRetryable(String),
418 #[error("Timeout error: {0}")]
419 Timeout(String),
420 }
421
422 pub(super) fn should_retry_test_error(error: &TestError) -> bool {
423 matches!(error, TestError::Retryable(_))
424 }
425
426 pub(super) fn create_test_error(msg: String) -> TestError {
427 TestError::Timeout(msg)
428 }
429}
430
431#[cfg(test)]
440mod tests {
441 use std::sync::{
442 Arc,
443 atomic::{AtomicU32, Ordering},
444 };
445
446 #[cfg(all(feature = "simulation", madsim))]
447 use madsim::task::{spawn, yield_now};
448 use nautilus_core::MUTEX_POISONED;
449 use rstest::rstest;
450 #[cfg(not(all(feature = "simulation", madsim)))]
451 use tokio::task::{spawn, yield_now};
452
453 use super::{test_utils::*, *};
454 use crate::dst::time;
455
456 const MAX_WAIT_ITERS: usize = 10_000;
457 const MAX_ADVANCE_ITERS: usize = 10_000;
458
459 #[cfg(all(feature = "simulation", madsim))]
460 pub(crate) async fn advance_clock(d: Duration) {
461 madsim::time::advance(d);
462 madsim::task::yield_now().await;
463 }
464
465 #[cfg(not(all(feature = "simulation", madsim)))]
466 pub(crate) async fn advance_clock(d: Duration) {
467 tokio::time::advance(d).await;
468 }
469
470 pub(crate) async fn yield_until<F>(mut condition: F)
471 where
472 F: FnMut() -> bool,
473 {
474 for _ in 0..MAX_WAIT_ITERS {
475 if condition() {
476 return;
477 }
478 yield_now().await;
479 }
480
481 panic!("yield_until timed out waiting for condition");
482 }
483
484 pub(crate) async fn advance_until<F>(mut condition: F)
485 where
486 F: FnMut() -> bool,
487 {
488 for _ in 0..MAX_ADVANCE_ITERS {
489 if condition() {
490 return;
491 }
492 advance_clock(Duration::from_millis(1)).await;
493 yield_now().await;
494 }
495
496 panic!("advance_until timed out waiting for condition");
497 }
498
499 #[rstest]
500 fn test_retry_config_default() {
501 let config = RetryConfig::default();
502 assert_eq!(config.max_retries, 3);
503 assert_eq!(config.initial_delay_ms, 1_000);
504 assert_eq!(config.max_delay_ms, 10_000);
505 #[expect(clippy::float_cmp, reason = "test asserts the default backoff factor")]
506 {
507 assert_eq!(config.backoff_factor, 2.0);
508 }
509 assert_eq!(config.jitter_ms, 100);
510 assert_eq!(config.operation_timeout_ms, Some(30_000));
511 assert!(!config.immediate_first);
512 assert_eq!(config.max_elapsed_ms, None);
513 }
514
515 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
516 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
517 async fn test_retry_manager_success_first_attempt() {
518 let manager = RetryManager::new(RetryConfig::default());
519
520 let result = manager
521 .execute_with_retry(
522 "test_operation",
523 || async { Ok::<i32, TestError>(42) },
524 should_retry_test_error,
525 create_test_error,
526 )
527 .await;
528
529 assert_eq!(result.unwrap(), 42);
530 }
531
532 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
533 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
534 async fn test_retry_manager_non_retryable_error() {
535 let manager = RetryManager::new(RetryConfig::default());
536
537 let result = manager
538 .execute_with_retry(
539 "test_operation",
540 || async { Err::<i32, TestError>(TestError::NonRetryable("test".to_string())) },
541 should_retry_test_error,
542 create_test_error,
543 )
544 .await;
545
546 assert!(result.is_err());
547 assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
548 }
549
550 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
551 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
552 async fn test_retry_manager_retryable_error_exhausted() {
553 let config = RetryConfig {
554 max_retries: 2,
555 initial_delay_ms: 10,
556 max_delay_ms: 50,
557 backoff_factor: 2.0,
558 jitter_ms: 0,
559 operation_timeout_ms: None,
560 immediate_first: false,
561 max_elapsed_ms: None,
562 };
563 let manager = RetryManager::new(config);
564
565 let result = manager
566 .execute_with_retry(
567 "test_operation",
568 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
569 should_retry_test_error,
570 create_test_error,
571 )
572 .await;
573
574 assert!(result.is_err());
575 assert!(matches!(result.unwrap_err(), TestError::Retryable(_)));
576 }
577
578 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
579 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
580 async fn test_timeout_path() {
581 let config = RetryConfig {
582 max_retries: 2,
583 initial_delay_ms: 10,
584 max_delay_ms: 50,
585 backoff_factor: 2.0,
586 jitter_ms: 0,
587 operation_timeout_ms: Some(50),
588 immediate_first: false,
589 max_elapsed_ms: None,
590 };
591 let manager = RetryManager::new(config);
592
593 let result = manager
594 .execute_with_retry(
595 "test_timeout",
596 || async {
597 time::sleep(Duration::from_millis(100)).await;
598 Ok::<i32, TestError>(42)
599 },
600 should_retry_test_error,
601 create_test_error,
602 )
603 .await;
604
605 assert!(result.is_err());
606 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
607 }
608
609 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
610 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
611 async fn test_max_elapsed_time_budget() {
612 let config = RetryConfig {
613 max_retries: 10,
614 initial_delay_ms: 50,
615 max_delay_ms: 100,
616 backoff_factor: 2.0,
617 jitter_ms: 0,
618 operation_timeout_ms: None,
619 immediate_first: false,
620 max_elapsed_ms: Some(200),
621 };
622 let manager = RetryManager::new(config);
623
624 let start = time::Instant::now();
625 let result = manager
626 .execute_with_retry(
627 "test_budget",
628 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
629 should_retry_test_error,
630 create_test_error,
631 )
632 .await;
633
634 let elapsed = start.elapsed();
635 assert!(result.is_err());
636 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
637 assert!(elapsed.as_millis() >= 150);
638 assert!(elapsed.as_millis() < 1000);
639 }
640
641 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
642 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
643 async fn test_budget_exceeded_message_format() {
644 let config = RetryConfig {
645 max_retries: 5,
646 initial_delay_ms: 10,
647 max_delay_ms: 20,
648 backoff_factor: 1.0,
649 jitter_ms: 0,
650 operation_timeout_ms: None,
651 immediate_first: false,
652 max_elapsed_ms: Some(35),
653 };
654 let manager = RetryManager::new(config);
655
656 let result = manager
657 .execute_with_retry(
658 "test_budget_msg",
659 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
660 should_retry_test_error,
661 create_test_error,
662 )
663 .await;
664
665 assert!(result.is_err());
666 let error_msg = result.unwrap_err().to_string();
667
668 assert!(error_msg.contains("Retry budget exceeded"));
669 assert!(error_msg.contains("/6)"));
670
671 if let Some(captures) = error_msg.strip_prefix("Timeout error: Retry budget exceeded (")
672 && let Some(nums) = captures.strip_suffix(")")
673 {
674 let parts: Vec<&str> = nums.split('/').collect();
675 assert_eq!(parts.len(), 2);
676 let current: u32 = parts[0].parse().unwrap();
677 let total: u32 = parts[1].parse().unwrap();
678
679 assert_eq!(total, 6, "Total should be max_retries + 1");
680 assert!(current <= total, "Current attempt should not exceed total");
681 assert!(current >= 1, "Current attempt should be at least 1");
682 }
683 }
684
685 #[cfg_attr(
686 not(all(feature = "simulation", madsim)),
687 tokio::test(start_paused = true)
688 )]
689 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
690 async fn test_budget_exceeded_edge_cases() {
691 let config = RetryConfig {
692 max_retries: 2,
693 initial_delay_ms: 50,
694 max_delay_ms: 100,
695 backoff_factor: 1.0,
696 jitter_ms: 0,
697 operation_timeout_ms: None,
698 immediate_first: false,
699 max_elapsed_ms: Some(100),
700 };
701 let manager = RetryManager::new(config);
702
703 let attempt_count = Arc::new(AtomicU32::new(0));
704 let count_clone = attempt_count.clone();
705
706 let handle = spawn(async move {
707 manager
708 .execute_with_retry(
709 "test_first_attempt",
710 move || {
711 let count = count_clone.clone();
712 async move {
713 count.fetch_add(1, Ordering::SeqCst);
714 Err::<i32, TestError>(TestError::Retryable("test".to_string()))
715 }
716 },
717 should_retry_test_error,
718 create_test_error,
719 )
720 .await
721 });
722
723 yield_until(|| attempt_count.load(Ordering::SeqCst) >= 1).await;
725
726 advance_clock(Duration::from_millis(101)).await;
728 yield_now().await;
729
730 let result = handle.await.unwrap();
731 assert!(result.is_err());
732 let error_msg = result.unwrap_err().to_string();
733
734 assert!(
736 error_msg.contains("(2/3)"),
737 "Expected (2/3) but got: {error_msg}"
738 );
739 }
740
741 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
742 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
743 async fn test_budget_exceeded_no_overflow() {
744 let config = RetryConfig {
745 max_retries: u32::MAX,
746 initial_delay_ms: 10,
747 max_delay_ms: 20,
748 backoff_factor: 1.0,
749 jitter_ms: 0,
750 operation_timeout_ms: None,
751 immediate_first: false,
752 max_elapsed_ms: Some(1),
753 };
754 let manager = RetryManager::new(config);
755
756 let result = manager
757 .execute_with_retry(
758 "test_overflow",
759 || async { Err::<i32, TestError>(TestError::Retryable("test".to_string())) },
760 should_retry_test_error,
761 create_test_error,
762 )
763 .await;
764
765 assert!(result.is_err());
766 let error_msg = result.unwrap_err().to_string();
767
768 assert!(error_msg.contains("Retry budget exceeded"));
770 assert!(error_msg.contains(&format!("/{}", u32::MAX)));
771 }
772
773 #[rstest]
774 fn test_http_retry_manager_config() {
775 let manager = create_http_retry_manager::<TestError>();
776 assert_eq!(manager.config.max_retries, 3);
777 assert!(!manager.config.immediate_first);
778 assert_eq!(manager.config.max_elapsed_ms, Some(180_000));
779 }
780
781 #[rstest]
782 fn test_websocket_retry_manager_config() {
783 let manager = create_websocket_retry_manager::<TestError>();
784 assert_eq!(manager.config.max_retries, 5);
785 assert!(manager.config.immediate_first);
786 assert_eq!(manager.config.max_elapsed_ms, Some(120_000));
787 }
788
789 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
790 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
791 async fn test_timeout_respects_retry_predicate() {
792 let config = RetryConfig {
793 max_retries: 3,
794 initial_delay_ms: 10,
795 max_delay_ms: 50,
796 backoff_factor: 2.0,
797 jitter_ms: 0,
798 operation_timeout_ms: Some(50),
799 immediate_first: false,
800 max_elapsed_ms: None,
801 };
802 let manager = RetryManager::new(config);
803
804 let should_not_retry_timeouts = |error: &TestError| !matches!(error, TestError::Timeout(_));
806
807 let result = manager
808 .execute_with_retry(
809 "test_timeout_non_retryable",
810 || async {
811 time::sleep(Duration::from_millis(100)).await;
812 Ok::<i32, TestError>(42)
813 },
814 should_not_retry_timeouts,
815 create_test_error,
816 )
817 .await;
818
819 assert!(result.is_err());
821 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
822 }
823
824 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
825 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
826 async fn test_timeout_retries_when_predicate_allows() {
827 let config = RetryConfig {
828 max_retries: 2,
829 initial_delay_ms: 10,
830 max_delay_ms: 50,
831 backoff_factor: 2.0,
832 jitter_ms: 0,
833 operation_timeout_ms: Some(50),
834 immediate_first: false,
835 max_elapsed_ms: None,
836 };
837 let manager = RetryManager::new(config);
838
839 let should_retry_timeouts = |error: &TestError| matches!(error, TestError::Timeout(_));
841
842 let start = time::Instant::now();
843 let result = manager
844 .execute_with_retry(
845 "test_timeout_retryable",
846 || async {
847 time::sleep(Duration::from_millis(100)).await;
848 Ok::<i32, TestError>(42)
849 },
850 should_retry_timeouts,
851 create_test_error,
852 )
853 .await;
854
855 let elapsed = start.elapsed();
856
857 assert!(result.is_err());
859 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
860 assert!(elapsed.as_millis() > 80); }
863
864 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
865 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
866 async fn test_successful_retry_after_failures() {
867 let config = RetryConfig {
868 max_retries: 3,
869 initial_delay_ms: 10,
870 max_delay_ms: 50,
871 backoff_factor: 2.0,
872 jitter_ms: 0,
873 operation_timeout_ms: None,
874 immediate_first: false,
875 max_elapsed_ms: None,
876 };
877 let manager = RetryManager::new(config);
878
879 let attempt_counter = Arc::new(AtomicU32::new(0));
880 let counter_clone = attempt_counter.clone();
881
882 let result = manager
883 .execute_with_retry(
884 "test_eventual_success",
885 move || {
886 let counter = counter_clone.clone();
887 async move {
888 let attempts = counter.fetch_add(1, Ordering::SeqCst);
889 if attempts < 2 {
890 Err(TestError::Retryable("temporary failure".to_string()))
891 } else {
892 Ok(42)
893 }
894 }
895 },
896 should_retry_test_error,
897 create_test_error,
898 )
899 .await;
900
901 assert_eq!(result.unwrap(), 42);
902 assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
903 }
904
905 #[cfg_attr(
906 not(all(feature = "simulation", madsim)),
907 tokio::test(start_paused = true)
908 )]
909 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
910 async fn test_immediate_first_retry() {
911 let config = RetryConfig {
912 max_retries: 2,
913 initial_delay_ms: 100,
914 max_delay_ms: 200,
915 backoff_factor: 2.0,
916 jitter_ms: 0,
917 operation_timeout_ms: None,
918 immediate_first: true,
919 max_elapsed_ms: None,
920 };
921 let manager = RetryManager::new(config);
922
923 let attempt_times = Arc::new(std::sync::Mutex::new(Vec::new()));
924 let times_clone = attempt_times.clone();
925 let start = time::Instant::now();
926
927 let handle = spawn({
928 let times_clone = times_clone.clone();
929 async move {
930 let _ = manager
931 .execute_with_retry(
932 "test_immediate",
933 move || {
934 let times = times_clone.clone();
935 async move {
936 times.lock().expect(MUTEX_POISONED).push(start.elapsed());
937 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
938 }
939 },
940 should_retry_test_error,
941 create_test_error,
942 )
943 .await;
944 }
945 });
946
947 yield_until(|| attempt_times.lock().expect(MUTEX_POISONED).len() >= 2).await;
949
950 advance_clock(Duration::from_millis(100)).await;
952 yield_now().await;
953
954 yield_until(|| attempt_times.lock().expect(MUTEX_POISONED).len() >= 3).await;
956
957 handle.await.unwrap();
958
959 let times = attempt_times.lock().expect(MUTEX_POISONED);
960 assert_eq!(times.len(), 3); assert!(times[1] <= Duration::from_millis(1));
964 assert!(times[2] >= Duration::from_millis(100));
966 assert!(times[2] <= Duration::from_millis(110));
967 }
968
969 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
970 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
971 async fn test_operation_without_timeout() {
972 let config = RetryConfig {
973 max_retries: 2,
974 initial_delay_ms: 10,
975 max_delay_ms: 50,
976 backoff_factor: 2.0,
977 jitter_ms: 0,
978 operation_timeout_ms: None, immediate_first: false,
980 max_elapsed_ms: None,
981 };
982 let manager = RetryManager::new(config);
983
984 let start = time::Instant::now();
985 let result = manager
986 .execute_with_retry(
987 "test_no_timeout",
988 || async {
989 time::sleep(Duration::from_millis(50)).await;
990 Ok::<i32, TestError>(42)
991 },
992 should_retry_test_error,
993 create_test_error,
994 )
995 .await;
996
997 let elapsed = start.elapsed();
998 assert_eq!(result.unwrap(), 42);
999 assert!(elapsed.as_millis() >= 30);
1001 assert!(elapsed.as_millis() < 200);
1002 }
1003
1004 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1005 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1006 async fn test_zero_retries() {
1007 let config = RetryConfig {
1008 max_retries: 0,
1009 initial_delay_ms: 10,
1010 max_delay_ms: 50,
1011 backoff_factor: 2.0,
1012 jitter_ms: 0,
1013 operation_timeout_ms: None,
1014 immediate_first: false,
1015 max_elapsed_ms: None,
1016 };
1017 let manager = RetryManager::new(config);
1018
1019 let attempt_counter = Arc::new(AtomicU32::new(0));
1020 let counter_clone = attempt_counter.clone();
1021
1022 let result = manager
1023 .execute_with_retry(
1024 "test_no_retries",
1025 move || {
1026 let counter = counter_clone.clone();
1027 async move {
1028 counter.fetch_add(1, Ordering::SeqCst);
1029 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1030 }
1031 },
1032 should_retry_test_error,
1033 create_test_error,
1034 )
1035 .await;
1036
1037 assert!(result.is_err());
1038 assert_eq!(attempt_counter.load(Ordering::SeqCst), 1);
1040 }
1041
1042 #[cfg_attr(
1043 not(all(feature = "simulation", madsim)),
1044 tokio::test(start_paused = true)
1045 )]
1046 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1047 async fn test_jitter_applied() {
1048 let config = RetryConfig {
1049 max_retries: 2,
1050 initial_delay_ms: 50,
1051 max_delay_ms: 100,
1052 backoff_factor: 2.0,
1053 jitter_ms: 50, operation_timeout_ms: None,
1055 immediate_first: false,
1056 max_elapsed_ms: None,
1057 };
1058 let manager = RetryManager::new(config);
1059
1060 let delays = Arc::new(std::sync::Mutex::new(Vec::new()));
1061 let delays_clone = delays.clone();
1062 let last_time = Arc::new(std::sync::Mutex::new(time::Instant::now()));
1063 let last_time_clone = last_time.clone();
1064
1065 let handle = spawn({
1066 let delays_clone = delays_clone.clone();
1067 async move {
1068 let _ = manager
1069 .execute_with_retry(
1070 "test_jitter",
1071 move || {
1072 let delays = delays_clone.clone();
1073 let last_time = last_time_clone.clone();
1074 async move {
1075 let now = time::Instant::now();
1076 let delay = {
1077 let mut last = last_time.lock().expect(MUTEX_POISONED);
1078 let d = now.duration_since(*last);
1079 *last = now;
1080 d
1081 };
1082 delays.lock().expect(MUTEX_POISONED).push(delay);
1083 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1084 }
1085 },
1086 should_retry_test_error,
1087 create_test_error,
1088 )
1089 .await;
1090 }
1091 });
1092
1093 yield_until(|| !delays.lock().expect(MUTEX_POISONED).is_empty()).await;
1094 advance_until(|| delays.lock().expect(MUTEX_POISONED).len() >= 2).await;
1095 advance_until(|| delays.lock().expect(MUTEX_POISONED).len() >= 3).await;
1096
1097 handle.await.unwrap();
1098
1099 let delays = delays.lock().expect(MUTEX_POISONED);
1100 for delay in delays.iter().skip(1) {
1102 assert!(delay.as_millis() >= 50);
1104 assert!(delay.as_millis() <= 151);
1106 }
1107 }
1108
1109 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1110 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1111 async fn test_max_elapsed_stops_early() {
1112 let config = RetryConfig {
1113 max_retries: 100, initial_delay_ms: 50,
1115 max_delay_ms: 100,
1116 backoff_factor: 1.5,
1117 jitter_ms: 0,
1118 operation_timeout_ms: None,
1119 immediate_first: false,
1120 max_elapsed_ms: Some(150), };
1122 let manager = RetryManager::new(config);
1123
1124 let attempt_counter = Arc::new(AtomicU32::new(0));
1125 let counter_clone = attempt_counter.clone();
1126
1127 let start = time::Instant::now();
1128 let result = manager
1129 .execute_with_retry(
1130 "test_elapsed_limit",
1131 move || {
1132 let counter = counter_clone.clone();
1133 async move {
1134 counter.fetch_add(1, Ordering::SeqCst);
1135 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1136 }
1137 },
1138 should_retry_test_error,
1139 create_test_error,
1140 )
1141 .await;
1142
1143 let elapsed = start.elapsed();
1144 assert!(result.is_err());
1145 assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1146
1147 let attempts = attempt_counter.load(Ordering::SeqCst);
1149 assert!(attempts < 10); assert!(elapsed.as_millis() >= 100);
1151 }
1152
1153 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1154 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1155 async fn test_mixed_errors_retry_behavior() {
1156 let config = RetryConfig {
1157 max_retries: 5,
1158 initial_delay_ms: 10,
1159 max_delay_ms: 50,
1160 backoff_factor: 2.0,
1161 jitter_ms: 0,
1162 operation_timeout_ms: None,
1163 immediate_first: false,
1164 max_elapsed_ms: None,
1165 };
1166 let manager = RetryManager::new(config);
1167
1168 let attempt_counter = Arc::new(AtomicU32::new(0));
1169 let counter_clone = attempt_counter.clone();
1170
1171 let result = manager
1172 .execute_with_retry(
1173 "test_mixed_errors",
1174 move || {
1175 let counter = counter_clone.clone();
1176 async move {
1177 let attempts = counter.fetch_add(1, Ordering::SeqCst);
1178 match attempts {
1179 0 => Err(TestError::Retryable("retry 1".to_string())),
1180 1 => Err(TestError::Retryable("retry 2".to_string())),
1181 2 => Err(TestError::NonRetryable("stop here".to_string())),
1182 _ => Ok(42),
1183 }
1184 }
1185 },
1186 should_retry_test_error,
1187 create_test_error,
1188 )
1189 .await;
1190
1191 assert!(result.is_err());
1192 assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
1193 assert_eq!(attempt_counter.load(Ordering::SeqCst), 3);
1195 }
1196
1197 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1198 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1199 async fn test_cancellation_during_retry_delay() {
1200 use tokio_util::sync::CancellationToken;
1201
1202 let config = RetryConfig {
1203 max_retries: 10,
1204 initial_delay_ms: 500, max_delay_ms: 1000,
1206 backoff_factor: 2.0,
1207 jitter_ms: 0,
1208 operation_timeout_ms: None,
1209 immediate_first: false,
1210 max_elapsed_ms: None,
1211 };
1212 let manager = RetryManager::new(config);
1213
1214 let token = CancellationToken::new();
1215 let token_clone = token.clone();
1216
1217 spawn(async move {
1219 time::sleep(Duration::from_millis(100)).await;
1220 token_clone.cancel();
1221 });
1222
1223 let attempt_counter = Arc::new(AtomicU32::new(0));
1224 let counter_clone = attempt_counter.clone();
1225
1226 let start = time::Instant::now();
1227 let result = manager
1228 .execute_with_retry_with_cancel(
1229 "test_cancellation",
1230 move || {
1231 let counter = counter_clone.clone();
1232 async move {
1233 counter.fetch_add(1, Ordering::SeqCst);
1234 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1235 }
1236 },
1237 should_retry_test_error,
1238 create_test_error,
1239 &token,
1240 )
1241 .await;
1242
1243 let elapsed = start.elapsed();
1244
1245 assert!(result.is_err());
1247 let error_msg = format!("{}", result.unwrap_err());
1248 assert!(error_msg.contains("canceled"));
1249
1250 assert!(elapsed.as_millis() < 600);
1252
1253 let attempts = attempt_counter.load(Ordering::SeqCst);
1255 assert!(attempts >= 1);
1256 }
1257
1258 #[cfg_attr(not(all(feature = "simulation", madsim)), tokio::test)]
1259 #[cfg_attr(all(feature = "simulation", madsim), madsim::test)]
1260 async fn test_cancellation_during_operation_execution() {
1261 use tokio_util::sync::CancellationToken;
1262
1263 let config = RetryConfig {
1264 max_retries: 5,
1265 initial_delay_ms: 50,
1266 max_delay_ms: 100,
1267 backoff_factor: 2.0,
1268 jitter_ms: 0,
1269 operation_timeout_ms: None,
1270 immediate_first: false,
1271 max_elapsed_ms: None,
1272 };
1273 let manager = RetryManager::new(config);
1274
1275 let token = CancellationToken::new();
1276 let token_clone = token.clone();
1277
1278 spawn(async move {
1280 time::sleep(Duration::from_millis(50)).await;
1281 token_clone.cancel();
1282 });
1283
1284 let start = time::Instant::now();
1285 let result = manager
1286 .execute_with_retry_with_cancel(
1287 "test_cancellation_during_op",
1288 || async {
1289 time::sleep(Duration::from_millis(200)).await;
1291 Ok::<i32, TestError>(42)
1292 },
1293 should_retry_test_error,
1294 create_test_error,
1295 &token,
1296 )
1297 .await;
1298
1299 let elapsed = start.elapsed();
1300
1301 assert!(result.is_err());
1303 let error_msg = format!("{}", result.unwrap_err());
1304 assert!(error_msg.contains("canceled"));
1305
1306 assert!(elapsed.as_millis() < 250);
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_cancellation_error_message() {
1313 use tokio_util::sync::CancellationToken;
1314
1315 let config = RetryConfig::default();
1316 let manager = RetryManager::new(config);
1317
1318 let token = CancellationToken::new();
1319 token.cancel(); let result = manager
1322 .execute_with_retry_with_cancel(
1323 "test_operation",
1324 || async { Ok::<i32, TestError>(42) },
1325 should_retry_test_error,
1326 create_test_error,
1327 &token,
1328 )
1329 .await;
1330
1331 assert!(result.is_err());
1332 let error_msg = format!("{}", result.unwrap_err());
1333 assert!(error_msg.contains("canceled"));
1334 }
1335}
1336
1337#[cfg(test)]
1338mod proptest_tests {
1339 use std::sync::{
1340 Arc,
1341 atomic::{AtomicU32, Ordering},
1342 };
1343
1344 #[cfg(all(feature = "simulation", madsim))]
1345 use madsim::task::spawn;
1346 use nautilus_core::MUTEX_POISONED;
1347 use proptest::prelude::*;
1348 use rstest::rstest;
1350 #[cfg(not(all(feature = "simulation", madsim)))]
1351 use tokio::task::spawn;
1352
1353 #[cfg(not(all(feature = "simulation", madsim)))]
1354 use super::tests::{advance_until, yield_until};
1355 use super::{test_utils::*, tests::advance_clock, *};
1356 use crate::dst::time;
1357
1358 #[cfg(all(feature = "simulation", madsim))]
1363 fn build_paused_runtime() -> madsim::runtime::Runtime {
1364 madsim::runtime::Runtime::new()
1365 }
1366
1367 #[cfg(not(all(feature = "simulation", madsim)))]
1368 fn build_paused_runtime() -> tokio::runtime::Runtime {
1369 tokio::runtime::Builder::new_current_thread()
1370 .enable_time()
1371 .start_paused(true)
1372 .build()
1373 .unwrap()
1374 }
1375
1376 proptest! {
1377 #[rstest]
1378 fn test_retry_config_valid_ranges(
1379 max_retries in 0u32..100,
1380 initial_delay_ms in 1u64..10_000,
1381 max_delay_ms in 1u64..60_000,
1382 backoff_factor in 1.0f64..10.0,
1383 jitter_ms in 0u64..1_000,
1384 operation_timeout_ms in prop::option::of(1u64..120_000),
1385 immediate_first in any::<bool>(),
1386 max_elapsed_ms in prop::option::of(1u64..300_000)
1387 ) {
1388 let max_delay_ms = max_delay_ms.max(initial_delay_ms);
1390
1391 let config = RetryConfig {
1392 max_retries,
1393 initial_delay_ms,
1394 max_delay_ms,
1395 backoff_factor,
1396 jitter_ms,
1397 operation_timeout_ms,
1398 immediate_first,
1399 max_elapsed_ms,
1400 };
1401
1402 let _manager = RetryManager::<std::io::Error>::new(config);
1404 }
1405
1406 #[rstest]
1407 fn test_retry_attempts_bounded(
1408 max_retries in 0u32..5,
1409 initial_delay_ms in 1u64..10,
1410 backoff_factor in 1.0f64..2.0,
1411 ) {
1412 let rt = build_paused_runtime();
1413
1414 let config = RetryConfig {
1415 max_retries,
1416 initial_delay_ms,
1417 max_delay_ms: initial_delay_ms * 2,
1418 backoff_factor,
1419 jitter_ms: 0,
1420 operation_timeout_ms: None,
1421 immediate_first: false,
1422 max_elapsed_ms: None,
1423 };
1424
1425 let manager = RetryManager::new(config);
1426 let attempt_counter = Arc::new(AtomicU32::new(0));
1427 let counter_clone = attempt_counter.clone();
1428
1429 let _result = rt.block_on(manager.execute_with_retry(
1430 "prop_test",
1431 move || {
1432 let counter = counter_clone.clone();
1433 async move {
1434 counter.fetch_add(1, Ordering::SeqCst);
1435 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1436 }
1437 },
1438 |e: &TestError| matches!(e, TestError::Retryable(_)),
1439 TestError::Timeout,
1440 ));
1441
1442 let attempts = attempt_counter.load(Ordering::SeqCst);
1443 prop_assert_eq!(attempts, max_retries + 1);
1445 }
1446
1447 #[rstest]
1448 fn test_timeout_always_respected(
1449 timeout_ms in 10u64..50,
1450 operation_delay_ms in 60u64..100,
1451 ) {
1452 let rt = build_paused_runtime();
1453
1454 let config = RetryConfig {
1455 max_retries: 0, initial_delay_ms: 10,
1457 max_delay_ms: 100,
1458 backoff_factor: 2.0,
1459 jitter_ms: 0,
1460 operation_timeout_ms: Some(timeout_ms),
1461 immediate_first: false,
1462 max_elapsed_ms: None,
1463 };
1464
1465 let manager = RetryManager::new(config);
1466
1467 let result = rt.block_on(async {
1468 let operation_future = manager.execute_with_retry(
1469 "timeout_test",
1470 move || async move {
1471 time::sleep(Duration::from_millis(operation_delay_ms)).await;
1472 Ok::<i32, TestError>(42)
1473 },
1474 |_: &TestError| true,
1475 TestError::Timeout,
1476 );
1477
1478 advance_clock(Duration::from_millis(timeout_ms + 10)).await;
1480 operation_future.await
1481 });
1482
1483 prop_assert!(result.is_err());
1485 prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1486 }
1487
1488 #[rstest]
1489 fn test_max_elapsed_always_respected(
1490 max_elapsed_ms in 20u64..50,
1491 delay_per_retry in 15u64..30,
1492 max_retries in 10u32..20,
1493 ) {
1494 let rt = build_paused_runtime();
1495
1496 let config = RetryConfig {
1498 max_retries,
1499 initial_delay_ms: delay_per_retry,
1500 max_delay_ms: delay_per_retry * 2,
1501 backoff_factor: 1.0, jitter_ms: 0,
1503 operation_timeout_ms: None,
1504 immediate_first: false,
1505 max_elapsed_ms: Some(max_elapsed_ms),
1506 };
1507
1508 let manager = RetryManager::new(config);
1509 let attempt_counter = Arc::new(AtomicU32::new(0));
1510 let counter_clone = attempt_counter.clone();
1511
1512 let result = rt.block_on(async {
1513 let operation_future = manager.execute_with_retry(
1514 "elapsed_test",
1515 move || {
1516 let counter = counter_clone.clone();
1517 async move {
1518 counter.fetch_add(1, Ordering::SeqCst);
1519 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1520 }
1521 },
1522 |e: &TestError| matches!(e, TestError::Retryable(_)),
1523 TestError::Timeout,
1524 );
1525
1526 advance_clock(Duration::from_millis(max_elapsed_ms + delay_per_retry)).await;
1528 operation_future.await
1529 });
1530
1531 let attempts = attempt_counter.load(Ordering::SeqCst);
1532
1533 prop_assert!(result.is_err());
1535 prop_assert!(matches!(result.unwrap_err(), TestError::Timeout(_)));
1536
1537 prop_assert!(attempts <= max_retries + 1);
1539 }
1540
1541 #[rstest]
1542 fn test_jitter_bounds(
1543 jitter_ms in 0u64..20,
1544 base_delay_ms in 10u64..30,
1545 ) {
1546 let rt = build_paused_runtime();
1547
1548 let config = RetryConfig {
1549 max_retries: 2,
1550 initial_delay_ms: base_delay_ms,
1551 max_delay_ms: base_delay_ms * 2,
1552 backoff_factor: 1.0, jitter_ms,
1554 operation_timeout_ms: None,
1555 immediate_first: false,
1556 max_elapsed_ms: None,
1557 };
1558
1559 let manager = RetryManager::new(config);
1560 let attempt_times = Arc::new(std::sync::Mutex::new(Vec::new()));
1561 let attempt_times_for_block = attempt_times.clone();
1562
1563 rt.block_on(async move {
1564 #[cfg(not(all(feature = "simulation", madsim)))]
1565 let attempt_times_for_wait = attempt_times_for_block.clone();
1566 let handle = spawn({
1567 let attempt_times_for_task = attempt_times_for_block.clone();
1568 let manager = manager;
1569 async move {
1570 let start_time = time::Instant::now();
1571 let _ = manager
1572 .execute_with_retry(
1573 "jitter_test",
1574 move || {
1575 let attempt_times_inner = attempt_times_for_task.clone();
1576 async move {
1577 attempt_times_inner
1578 .lock()
1579 .unwrap()
1580 .push(start_time.elapsed());
1581 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1582 }
1583 },
1584 |e: &TestError| matches!(e, TestError::Retryable(_)),
1585 TestError::Timeout,
1586 )
1587 .await;
1588 }
1589 });
1590
1591 #[cfg(not(all(feature = "simulation", madsim)))]
1596 {
1597 yield_until(|| !attempt_times_for_wait.lock().expect(MUTEX_POISONED).is_empty()).await;
1598 advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 2).await;
1599 advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 3).await;
1600 }
1601
1602 handle.await.unwrap();
1603 });
1604
1605 let times = attempt_times.lock().expect(MUTEX_POISONED);
1606
1607 prop_assert!(times.len() >= 2);
1609
1610 prop_assert!(times[0].as_millis() < 5);
1612
1613 for i in 1..times.len() {
1615 let delay_from_previous = if i == 1 {
1616 times[i].checked_sub(times[0]).unwrap()
1617 } else {
1618 times[i].checked_sub(times[i - 1]).unwrap()
1619 };
1620
1621 let floor = base_delay_ms.min((base_delay_ms * 2).saturating_sub(jitter_ms));
1624 prop_assert!(
1625 delay_from_previous.as_millis() >= u128::from(floor),
1626 "Retry {} delay {}ms is less than floor {}ms",
1627 i, delay_from_previous.as_millis(), floor
1628 );
1629
1630 prop_assert!(
1632 delay_from_previous.as_millis() <= u128::from(base_delay_ms + jitter_ms + 1),
1633 "Retry {} delay {}ms exceeds base {} + jitter {}",
1634 i, delay_from_previous.as_millis(), base_delay_ms, jitter_ms
1635 );
1636 }
1637 }
1638
1639 #[rstest]
1640 fn test_immediate_first_property(
1641 immediate_first in any::<bool>(),
1642 initial_delay_ms in 10u64..30,
1643 ) {
1644 let rt = build_paused_runtime();
1645
1646 let config = RetryConfig {
1647 max_retries: 2,
1648 initial_delay_ms,
1649 max_delay_ms: initial_delay_ms * 2,
1650 backoff_factor: 2.0,
1651 jitter_ms: 0,
1652 operation_timeout_ms: None,
1653 immediate_first,
1654 max_elapsed_ms: None,
1655 };
1656
1657 let manager = RetryManager::new(config);
1658 let attempt_times = Arc::new(std::sync::Mutex::new(Vec::new()));
1659 let attempt_times_for_block = attempt_times.clone();
1660
1661 rt.block_on(async move {
1662 #[cfg(not(all(feature = "simulation", madsim)))]
1663 let attempt_times_for_wait = attempt_times_for_block.clone();
1664 let handle = spawn({
1665 let attempt_times_for_task = attempt_times_for_block.clone();
1666 let manager = manager;
1667 async move {
1668 let start = time::Instant::now();
1669 let _ = manager
1670 .execute_with_retry(
1671 "immediate_test",
1672 move || {
1673 let attempt_times_inner = attempt_times_for_task.clone();
1674 async move {
1675 let elapsed = start.elapsed();
1676 attempt_times_inner.lock().expect(MUTEX_POISONED).push(elapsed);
1677 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1678 }
1679 },
1680 |e: &TestError| matches!(e, TestError::Retryable(_)),
1681 TestError::Timeout,
1682 )
1683 .await;
1684 }
1685 });
1686
1687 #[cfg(not(all(feature = "simulation", madsim)))]
1691 {
1692 yield_until(|| !attempt_times_for_wait.lock().expect(MUTEX_POISONED).is_empty()).await;
1693 advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 2).await;
1694 advance_until(|| attempt_times_for_wait.lock().expect(MUTEX_POISONED).len() >= 3).await;
1695 }
1696
1697 handle.await.unwrap();
1698 });
1699
1700 let times = attempt_times.lock().expect(MUTEX_POISONED);
1701 prop_assert!(times.len() >= 2);
1702
1703 if immediate_first {
1704 prop_assert!(times[1].as_millis() < 20,
1706 "With immediate_first=true, first retry took {}ms",
1707 times[1].as_millis());
1708 } else {
1709 prop_assert!(times[1].as_millis() >= u128::from(initial_delay_ms - 1),
1711 "With immediate_first=false, first retry was too fast: {}ms",
1712 times[1].as_millis());
1713 }
1714 }
1715
1716 #[rstest]
1717 fn test_non_retryable_stops_immediately(
1718 attempt_before_non_retryable in 0usize..3,
1719 max_retries in 3u32..5,
1720 ) {
1721 let rt = build_paused_runtime();
1722
1723 let config = RetryConfig {
1724 max_retries,
1725 initial_delay_ms: 10,
1726 max_delay_ms: 100,
1727 backoff_factor: 2.0,
1728 jitter_ms: 0,
1729 operation_timeout_ms: None,
1730 immediate_first: false,
1731 max_elapsed_ms: None,
1732 };
1733
1734 let manager = RetryManager::new(config);
1735 let attempt_counter = Arc::new(AtomicU32::new(0));
1736 let counter_clone = attempt_counter.clone();
1737
1738 let result: Result<i32, TestError> = rt.block_on(manager.execute_with_retry(
1739 "non_retryable_test",
1740 move || {
1741 let counter = counter_clone.clone();
1742 async move {
1743 let attempts = counter.fetch_add(1, Ordering::SeqCst) as usize;
1744 if attempts == attempt_before_non_retryable {
1745 Err(TestError::NonRetryable("stop".to_string()))
1746 } else {
1747 Err(TestError::Retryable("retry".to_string()))
1748 }
1749 }
1750 },
1751 |e: &TestError| matches!(e, TestError::Retryable(_)),
1752 TestError::Timeout,
1753 ));
1754
1755 let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
1756
1757 prop_assert!(result.is_err());
1758 prop_assert!(matches!(result.unwrap_err(), TestError::NonRetryable(_)));
1759 prop_assert_eq!(attempts, attempt_before_non_retryable + 1);
1761 }
1762
1763 #[rstest]
1764 fn test_cancellation_stops_immediately(
1765 cancel_after_ms in 10u64..100,
1766 initial_delay_ms in 200u64..500,
1767 ) {
1768 use tokio_util::sync::CancellationToken;
1769
1770 let rt = build_paused_runtime();
1771
1772 let config = RetryConfig {
1773 max_retries: 10,
1774 initial_delay_ms,
1775 max_delay_ms: initial_delay_ms * 2,
1776 backoff_factor: 2.0,
1777 jitter_ms: 0,
1778 operation_timeout_ms: None,
1779 immediate_first: false,
1780 max_elapsed_ms: None,
1781 };
1782
1783 let manager = RetryManager::new(config);
1784 let token = CancellationToken::new();
1785 let token_clone = token.clone();
1786
1787 let result: Result<i32, TestError> = rt.block_on(async {
1788 spawn(async move {
1790 time::sleep(Duration::from_millis(cancel_after_ms)).await;
1791 token_clone.cancel();
1792 });
1793
1794 let operation_future = manager.execute_with_retry_with_cancel(
1795 "cancellation_test",
1796 || async {
1797 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1798 },
1799 |e: &TestError| matches!(e, TestError::Retryable(_)),
1800 create_test_error,
1801 &token,
1802 );
1803
1804 advance_clock(Duration::from_millis(cancel_after_ms + 10)).await;
1806 operation_future.await
1807 });
1808
1809 prop_assert!(result.is_err());
1811 let error_msg = format!("{}", result.unwrap_err());
1812 prop_assert!(error_msg.contains("canceled"));
1813 }
1814
1815 #[rstest]
1816 fn test_budget_clamp_prevents_overshoot(
1817 max_elapsed_ms in 10u64..30,
1818 delay_per_retry in 20u64..50,
1819 ) {
1820 let rt = build_paused_runtime();
1821
1822 let config = RetryConfig {
1824 max_retries: 5,
1825 initial_delay_ms: delay_per_retry,
1826 max_delay_ms: delay_per_retry * 2,
1827 backoff_factor: 1.0,
1828 jitter_ms: 0,
1829 operation_timeout_ms: None,
1830 immediate_first: false,
1831 max_elapsed_ms: Some(max_elapsed_ms),
1832 };
1833
1834 let manager = RetryManager::new(config);
1835
1836 let _result = rt.block_on(async {
1837 let operation_future = manager.execute_with_retry(
1838 "budget_clamp_test",
1839 || async {
1840 Err::<i32, TestError>(TestError::Retryable("fail".to_string()))
1842 },
1843 |e: &TestError| matches!(e, TestError::Retryable(_)),
1844 create_test_error,
1845 );
1846
1847 advance_clock(Duration::from_millis(max_elapsed_ms + delay_per_retry)).await;
1849 operation_future.await
1850 });
1851
1852 }
1855
1856 #[rstest]
1857 fn test_success_on_kth_attempt(
1858 k in 1usize..5,
1859 initial_delay_ms in 5u64..20,
1860 ) {
1861 let rt = build_paused_runtime();
1862
1863 let config = RetryConfig {
1864 max_retries: 10, initial_delay_ms,
1866 max_delay_ms: initial_delay_ms * 4,
1867 backoff_factor: 2.0,
1868 jitter_ms: 0,
1869 operation_timeout_ms: None,
1870 immediate_first: false,
1871 max_elapsed_ms: None,
1872 };
1873
1874 let manager = RetryManager::new(config);
1875 let attempt_counter = Arc::new(AtomicU32::new(0));
1876 let counter_clone = attempt_counter.clone();
1877 let target_k = k;
1878
1879 let (result, _elapsed) = rt.block_on(async {
1880 let start = time::Instant::now();
1881
1882 let operation_future = manager.execute_with_retry(
1883 "kth_attempt_test",
1884 move || {
1885 let counter = counter_clone.clone();
1886 async move {
1887 let attempt = counter.fetch_add(1, Ordering::SeqCst) as usize;
1888 if attempt + 1 == target_k {
1889 Ok(42)
1890 } else {
1891 Err(TestError::Retryable("retry".to_string()))
1892 }
1893 }
1894 },
1895 |e: &TestError| matches!(e, TestError::Retryable(_)),
1896 create_test_error,
1897 );
1898
1899 for _ in 0..k {
1901 advance_clock(Duration::from_millis(initial_delay_ms * 4)).await;
1902 }
1903
1904 let result = operation_future.await;
1905 let elapsed = start.elapsed();
1906
1907 (result, elapsed)
1908 });
1909
1910 let attempts = attempt_counter.load(Ordering::SeqCst) as usize;
1911
1912 prop_assert!(result.is_ok());
1914 prop_assert_eq!(result.unwrap(), 42);
1915 prop_assert_eq!(attempts, k);
1916 }
1917 }
1918}