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