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