1use std::time::Duration;
30
31use serde::{Deserialize, Serialize};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum RetryStrategyType {
37 Immediate,
39 #[default]
41 Exponential,
42 Linear,
44 Constant,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum RetryPolicy {
52 #[default]
54 Default,
55 Aggressive,
57 Conservative,
59 Custom,
61}
62
63impl RetryPolicy {
64 pub fn to_config(&self) -> RetryStrategyConfig {
77 match self {
78 RetryPolicy::Default => RetryStrategyConfig {
79 strategy: RetryStrategyType::Exponential,
80 max_attempts: 6,
81 base_delay: Duration::from_secs(2),
82 max_delay: Duration::from_secs(120),
83 jitter: 0.5,
84 },
85 RetryPolicy::Aggressive => RetryStrategyConfig {
86 strategy: RetryStrategyType::Exponential,
87 max_attempts: 10,
88 base_delay: Duration::from_millis(500),
89 max_delay: Duration::from_secs(30),
90 jitter: 0.3,
91 },
92 RetryPolicy::Conservative => RetryStrategyConfig {
93 strategy: RetryStrategyType::Linear,
94 max_attempts: 3,
95 base_delay: Duration::from_secs(5),
96 max_delay: Duration::from_secs(60),
97 jitter: 0.1,
98 },
99 RetryPolicy::Custom => {
100 RetryStrategyConfig::default()
102 }
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RetryStrategyConfig {
110 #[serde(default)]
112 pub strategy: RetryStrategyType,
113 #[serde(default)]
115 pub max_attempts: u32,
116 #[serde(default = "default_base_delay")]
118 #[serde(with = "humantime_serde")]
119 pub base_delay: Duration,
120 #[serde(default = "default_max_delay")]
122 #[serde(with = "humantime_serde")]
123 pub max_delay: Duration,
124 #[serde(default = "default_jitter")]
126 pub jitter: f64,
127}
128
129fn default_base_delay() -> Duration {
130 Duration::from_secs(2)
131}
132
133fn default_max_delay() -> Duration {
134 Duration::from_secs(120)
135}
136
137fn default_jitter() -> f64 {
138 0.5
139}
140
141impl Default for RetryStrategyConfig {
142 fn default() -> Self {
143 Self {
144 strategy: RetryStrategyType::Exponential,
145 max_attempts: 6,
146 base_delay: Duration::from_secs(2),
147 max_delay: Duration::from_secs(120),
148 jitter: 0.5,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ErrorClass {
157 #[default]
159 Retryable,
160 Ambiguous,
162 Permanent,
164}
165
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct PerErrorConfig {
169 #[serde(default, rename = "retryable")]
171 pub retryable: Option<RetryStrategyConfig>,
172 #[serde(default, rename = "ambiguous")]
174 pub ambiguous: Option<RetryStrategyConfig>,
175 #[serde(default, rename = "permanent")]
178 pub permanent: Option<RetryStrategyConfig>,
179}
180
181pub fn calculate_delay(config: &RetryStrategyConfig, attempt: u32) -> Duration {
213 let delay = match config.strategy {
214 RetryStrategyType::Immediate => Duration::ZERO,
215 RetryStrategyType::Exponential => {
216 let pow = attempt.saturating_sub(1).min(16);
217 config.base_delay.saturating_mul(2_u32.saturating_pow(pow))
218 }
219 RetryStrategyType::Linear => config.base_delay.saturating_mul(attempt),
220 RetryStrategyType::Constant => config.base_delay,
221 };
222
223 let capped = delay.min(config.max_delay);
225
226 if config.jitter > 0.0 {
228 apply_jitter(capped, config.jitter)
229 } else {
230 capped
231 }
232}
233
234fn apply_jitter(delay: Duration, jitter: f64) -> Duration {
237 let jitter_range = 2.0 * jitter;
239 let random_value: f64 = rand::random();
240 let random_factor = 1.0 - jitter + (random_value * jitter_range);
241 let millis = (delay.as_millis() as f64 * random_factor).round() as u64;
242 Duration::from_millis(millis)
243}
244
245pub fn config_for_error(
282 default_config: &RetryStrategyConfig,
283 per_error_config: Option<&PerErrorConfig>,
284 error_class: ErrorClass,
285) -> RetryStrategyConfig {
286 if let Some(per_error) = per_error_config {
287 match error_class {
288 ErrorClass::Retryable => {
289 if let Some(config) = &per_error.retryable {
290 return config.clone();
291 }
292 }
293 ErrorClass::Ambiguous => {
294 if let Some(config) = &per_error.ambiguous {
295 return config.clone();
296 }
297 }
298 ErrorClass::Permanent => {
299 if let Some(config) = &per_error.permanent {
300 return config.clone();
301 }
302 }
303 }
304 }
305 default_config.clone()
306}
307
308pub struct RetryExecutor {
310 config: RetryStrategyConfig,
311}
312
313impl RetryExecutor {
314 pub fn new(config: RetryStrategyConfig) -> Self {
316 Self { config }
317 }
318
319 pub fn from_policy(policy: RetryPolicy) -> Self {
321 Self::new(policy.to_config())
322 }
323
324 pub fn run<T, E, F>(&self, mut operation: F) -> Result<T, E>
345 where
346 F: FnMut(u32) -> Result<T, E>,
347 {
348 let mut attempt = 1;
349
350 loop {
351 match operation(attempt) {
352 Ok(result) => return Ok(result),
353 Err(e) => {
354 if attempt >= self.config.max_attempts {
355 return Err(e);
356 }
357
358 let delay = calculate_delay(&self.config, attempt);
359 std::thread::sleep(delay);
360 attempt += 1;
361 }
362 }
363 }
364 }
365
366 pub fn run_with_classification<T, E, F>(&self, mut operation: F) -> Result<T, E>
371 where
372 F: FnMut(u32) -> Result<(T, bool), E>,
373 {
374 let mut attempt = 1;
375
376 loop {
377 match operation(attempt) {
378 Ok((result, _)) => return Ok(result),
379 Err(e) => {
380 if attempt >= self.config.max_attempts {
381 return Err(e);
382 }
383
384 let delay = calculate_delay(&self.config, attempt);
385 std::thread::sleep(delay);
386 attempt += 1;
387 }
388 }
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_retry_policy_to_config_default() {
399 let config = RetryPolicy::Default.to_config();
400 assert_eq!(config.strategy, RetryStrategyType::Exponential);
401 assert_eq!(config.max_attempts, 6);
402 assert_eq!(config.base_delay, Duration::from_secs(2));
403 assert_eq!(config.max_delay, Duration::from_secs(120));
404 }
405
406 #[test]
407 fn test_retry_policy_to_config_aggressive() {
408 let config = RetryPolicy::Aggressive.to_config();
409 assert_eq!(config.strategy, RetryStrategyType::Exponential);
410 assert_eq!(config.max_attempts, 10);
411 assert_eq!(config.base_delay, Duration::from_millis(500));
412 assert_eq!(config.max_delay, Duration::from_secs(30));
413 }
414
415 #[test]
416 fn test_retry_policy_to_config_conservative() {
417 let config = RetryPolicy::Conservative.to_config();
418 assert_eq!(config.strategy, RetryStrategyType::Linear);
419 assert_eq!(config.max_attempts, 3);
420 assert_eq!(config.base_delay, Duration::from_secs(5));
421 assert_eq!(config.max_delay, Duration::from_secs(60));
422 }
423
424 #[test]
425 fn test_calculate_delay_immediate() {
426 let config = RetryStrategyConfig {
427 strategy: RetryStrategyType::Immediate,
428 base_delay: Duration::from_secs(1),
429 max_delay: Duration::from_secs(60),
430 jitter: 0.0,
431 max_attempts: 3,
432 };
433
434 assert_eq!(calculate_delay(&config, 1), Duration::ZERO);
435 assert_eq!(calculate_delay(&config, 5), Duration::ZERO);
436 }
437
438 #[test]
439 fn test_calculate_delay_exponential() {
440 let config = RetryStrategyConfig {
441 strategy: RetryStrategyType::Exponential,
442 base_delay: Duration::from_secs(1),
443 max_delay: Duration::from_secs(60),
444 jitter: 0.0,
445 max_attempts: 10,
446 };
447
448 assert_eq!(calculate_delay(&config, 1), Duration::from_secs(1));
450
451 assert_eq!(calculate_delay(&config, 2), Duration::from_secs(2));
453
454 assert_eq!(calculate_delay(&config, 3), Duration::from_secs(4));
456
457 assert_eq!(calculate_delay(&config, 10), Duration::from_secs(60));
459 }
460
461 #[test]
462 fn test_calculate_delay_linear() {
463 let config = RetryStrategyConfig {
464 strategy: RetryStrategyType::Linear,
465 base_delay: Duration::from_secs(1),
466 max_delay: Duration::from_secs(10),
467 jitter: 0.0,
468 max_attempts: 10,
469 };
470
471 assert_eq!(calculate_delay(&config, 1), Duration::from_secs(1));
472 assert_eq!(calculate_delay(&config, 2), Duration::from_secs(2));
473 assert_eq!(calculate_delay(&config, 5), Duration::from_secs(5));
474 assert_eq!(calculate_delay(&config, 15), Duration::from_secs(10));
475 }
476
477 #[test]
478 fn test_calculate_delay_constant() {
479 let config = RetryStrategyConfig {
480 strategy: RetryStrategyType::Constant,
481 base_delay: Duration::from_secs(2),
482 max_delay: Duration::from_secs(10),
483 jitter: 0.0,
484 max_attempts: 10,
485 };
486
487 assert_eq!(calculate_delay(&config, 1), Duration::from_secs(2));
488 assert_eq!(calculate_delay(&config, 5), Duration::from_secs(2));
489 assert_eq!(calculate_delay(&config, 10), Duration::from_secs(2));
490 }
491
492 #[test]
493 fn test_calculate_delay_capped_at_max() {
494 let config = RetryStrategyConfig {
495 strategy: RetryStrategyType::Exponential,
496 base_delay: Duration::from_secs(10),
497 max_delay: Duration::from_secs(30),
498 jitter: 0.0,
499 max_attempts: 10,
500 };
501
502 assert_eq!(calculate_delay(&config, 1), Duration::from_secs(10));
503 assert_eq!(calculate_delay(&config, 2), Duration::from_secs(20));
504 assert_eq!(calculate_delay(&config, 3), Duration::from_secs(30));
505 assert_eq!(calculate_delay(&config, 10), Duration::from_secs(30));
506 }
507
508 #[test]
509 fn test_config_for_error_uses_defaults() {
510 let default_config = RetryStrategyConfig {
511 strategy: RetryStrategyType::Exponential,
512 max_attempts: 5,
513 base_delay: Duration::from_secs(1),
514 max_delay: Duration::from_secs(30),
515 jitter: 0.5,
516 };
517
518 let result = config_for_error(&default_config, None, ErrorClass::Retryable);
519 assert_eq!(result.max_attempts, 5);
520
521 let result = config_for_error(&default_config, None, ErrorClass::Permanent);
522 assert_eq!(result.max_attempts, 5);
523 }
524
525 #[test]
526 fn test_config_for_error_uses_per_error() {
527 let default_config = RetryStrategyConfig::default();
528
529 let per_error = PerErrorConfig {
530 retryable: Some(RetryStrategyConfig {
531 strategy: RetryStrategyType::Immediate,
532 max_attempts: 10,
533 base_delay: Duration::ZERO,
534 max_delay: Duration::ZERO,
535 jitter: 0.0,
536 }),
537 ambiguous: None,
538 permanent: None,
539 };
540
541 let result = config_for_error(&default_config, Some(&per_error), ErrorClass::Retryable);
543 assert_eq!(result.strategy, RetryStrategyType::Immediate);
544
545 let result = config_for_error(&default_config, Some(&per_error), ErrorClass::Ambiguous);
547 assert_eq!(result.strategy, RetryStrategyType::Exponential);
548 }
549
550 #[test]
551 fn test_retry_executor_success_on_first_try() {
552 let executor = RetryExecutor::from_policy(RetryPolicy::Aggressive);
553 let result = executor.run(|_attempt| Ok::<_, &str>("success"));
554 assert_eq!(result, Ok("success"));
555 }
556
557 #[test]
558 fn test_retry_executor_success_after_retries() {
559 let executor = RetryExecutor::new(RetryStrategyConfig {
560 strategy: RetryStrategyType::Immediate,
561 max_attempts: 5,
562 base_delay: Duration::ZERO,
563 max_delay: Duration::ZERO,
564 jitter: 0.0,
565 });
566
567 let mut attempts = 0;
568 let result = executor.run(|attempt| {
569 attempts = attempt;
570 if attempt < 3 {
571 Err("transient error")
572 } else {
573 Ok("success")
574 }
575 });
576
577 assert_eq!(result, Ok("success"));
578 assert_eq!(attempts, 3);
579 }
580
581 #[test]
582 fn test_retry_executor_fails_after_max_attempts() {
583 let executor = RetryExecutor::new(RetryStrategyConfig {
584 strategy: RetryStrategyType::Immediate,
585 max_attempts: 3,
586 base_delay: Duration::ZERO,
587 max_delay: Duration::ZERO,
588 jitter: 0.0,
589 });
590
591 let result = executor.run(|_attempt| Err::<&str, _>("permanent error"));
592 assert_eq!(result, Err("permanent error"));
593 }
594
595 #[test]
596 fn test_jitter_applied_correctly() {
597 let config = RetryStrategyConfig {
598 strategy: RetryStrategyType::Constant,
599 base_delay: Duration::from_secs(10),
600 max_delay: Duration::from_secs(60),
601 jitter: 0.5,
602 max_attempts: 10,
603 };
604
605 for _ in 0..100 {
607 let delay = calculate_delay(&config, 1);
608 assert!(delay >= Duration::from_millis(5000));
609 assert!(delay <= Duration::from_millis(15000));
610 }
611 }
612
613 #[test]
616 fn test_zero_max_retries_does_not_retry() {
617 let executor = RetryExecutor::new(RetryStrategyConfig {
618 strategy: RetryStrategyType::Immediate,
619 max_attempts: 0,
620 base_delay: Duration::ZERO,
621 max_delay: Duration::ZERO,
622 jitter: 0.0,
623 });
624
625 let mut call_count = 0u32;
626 let result = executor.run(|_attempt| {
627 call_count += 1;
628 Err::<(), _>("fail")
629 });
630
631 assert_eq!(result, Err("fail"));
632 assert_eq!(call_count, 1);
634 }
635
636 #[test]
637 fn test_zero_max_retries_succeeds_on_first_try() {
638 let executor = RetryExecutor::new(RetryStrategyConfig {
639 strategy: RetryStrategyType::Exponential,
640 max_attempts: 0,
641 base_delay: Duration::from_secs(1),
642 max_delay: Duration::from_secs(60),
643 jitter: 0.0,
644 });
645
646 let result = executor.run(|_| Ok::<_, &str>("ok"));
647 assert_eq!(result, Ok("ok"));
648 }
649
650 #[test]
651 fn test_very_large_max_retries_immediate_success() {
652 let executor = RetryExecutor::new(RetryStrategyConfig {
653 strategy: RetryStrategyType::Exponential,
654 max_attempts: 100,
655 base_delay: Duration::from_secs(60),
656 max_delay: Duration::from_secs(3600),
657 jitter: 0.5,
658 });
659
660 let mut call_count = 0u32;
661 let result = executor.run(|_attempt| {
662 call_count += 1;
663 Ok::<_, &str>("first try")
664 });
665
666 assert_eq!(result, Ok("first try"));
667 assert_eq!(call_count, 1);
668 }
669
670 #[test]
671 fn test_backoff_overflow_exponential_saturates() {
672 let config = RetryStrategyConfig {
674 strategy: RetryStrategyType::Exponential,
675 base_delay: Duration::from_secs(u64::MAX / 2),
676 max_delay: Duration::from_secs(u64::MAX / 2),
677 jitter: 0.0,
678 max_attempts: 100,
679 };
680
681 let delay = calculate_delay(&config, 17);
683 assert!(delay <= config.max_delay);
685 }
686
687 #[test]
688 fn test_backoff_overflow_linear_saturates() {
689 let config = RetryStrategyConfig {
690 strategy: RetryStrategyType::Linear,
691 base_delay: Duration::from_secs(u64::MAX / 2),
692 max_delay: Duration::from_secs(100),
693 jitter: 0.0,
694 max_attempts: 100,
695 };
696
697 let delay = calculate_delay(&config, u32::MAX);
698 assert!(delay <= config.max_delay);
699 }
700
701 #[test]
702 fn test_jitter_zero_produces_exact_delays() {
703 let config = RetryStrategyConfig {
704 strategy: RetryStrategyType::Exponential,
705 base_delay: Duration::from_millis(100),
706 max_delay: Duration::from_secs(60),
707 jitter: 0.0,
708 max_attempts: 10,
709 };
710
711 for attempt in 1..=5 {
713 let first = calculate_delay(&config, attempt);
714 for _ in 0..50 {
715 assert_eq!(calculate_delay(&config, attempt), first);
716 }
717 }
718 }
719
720 #[test]
721 fn test_jitter_one_full_range() {
722 let config = RetryStrategyConfig {
723 strategy: RetryStrategyType::Constant,
724 base_delay: Duration::from_secs(10),
725 max_delay: Duration::from_secs(60),
726 jitter: 1.0,
727 max_attempts: 10,
728 };
729
730 for _ in 0..200 {
733 let delay = calculate_delay(&config, 1);
734 assert!(delay <= Duration::from_millis(20_000));
735 }
736 }
737
738 #[test]
739 fn test_initial_delay_zero() {
740 for strategy in [
742 RetryStrategyType::Exponential,
743 RetryStrategyType::Linear,
744 RetryStrategyType::Constant,
745 RetryStrategyType::Immediate,
746 ] {
747 let config = RetryStrategyConfig {
748 strategy,
749 base_delay: Duration::ZERO,
750 max_delay: Duration::from_secs(60),
751 jitter: 0.0,
752 max_attempts: 10,
753 };
754
755 for attempt in 1..=5 {
756 assert_eq!(
757 calculate_delay(&config, attempt),
758 Duration::ZERO,
759 "strategy {:?} attempt {} should be zero with base_delay=ZERO",
760 strategy,
761 attempt
762 );
763 }
764 }
765 }
766
767 #[test]
768 fn test_max_delay_zero_caps_everything() {
769 let config = RetryStrategyConfig {
770 strategy: RetryStrategyType::Exponential,
771 base_delay: Duration::from_secs(10),
772 max_delay: Duration::ZERO,
773 jitter: 0.0,
774 max_attempts: 10,
775 };
776
777 for attempt in 1..=10 {
778 assert_eq!(
779 calculate_delay(&config, attempt),
780 Duration::ZERO,
781 "attempt {} should be capped to zero by max_delay=ZERO",
782 attempt
783 );
784 }
785 }
786
787 #[test]
788 fn test_max_delay_less_than_initial_delay() {
789 let config = RetryStrategyConfig {
790 strategy: RetryStrategyType::Exponential,
791 base_delay: Duration::from_secs(10),
792 max_delay: Duration::from_secs(5),
793 jitter: 0.0,
794 max_attempts: 10,
795 };
796
797 for attempt in 1..=5 {
799 assert_eq!(
800 calculate_delay(&config, attempt),
801 Duration::from_secs(5),
802 "attempt {} should be capped at max_delay when max_delay < base_delay",
803 attempt
804 );
805 }
806
807 let linear = RetryStrategyConfig {
809 strategy: RetryStrategyType::Linear,
810 ..config.clone()
811 };
812 assert_eq!(calculate_delay(&linear, 1), Duration::from_secs(5));
813
814 let constant = RetryStrategyConfig {
815 strategy: RetryStrategyType::Constant,
816 ..config
817 };
818 assert_eq!(calculate_delay(&constant, 1), Duration::from_secs(5));
819 }
820
821 #[test]
824 fn test_exponential_growth_doubles_each_attempt() {
825 let config = RetryStrategyConfig {
826 strategy: RetryStrategyType::Exponential,
827 base_delay: Duration::from_millis(100),
828 max_delay: Duration::from_secs(3600),
829 jitter: 0.0,
830 max_attempts: 20,
831 };
832 for attempt in 2..=10 {
833 let prev = calculate_delay(&config, attempt - 1);
834 let curr = calculate_delay(&config, attempt);
835 assert_eq!(
836 curr,
837 prev * 2,
838 "attempt {} should be double attempt {}",
839 attempt,
840 attempt - 1
841 );
842 }
843 }
844
845 #[test]
846 fn test_exponential_pow_clamped_at_16() {
847 let config = RetryStrategyConfig {
850 strategy: RetryStrategyType::Exponential,
851 base_delay: Duration::from_millis(1),
852 max_delay: Duration::from_secs(3600),
853 jitter: 0.0,
854 max_attempts: 30,
855 };
856 let at_17 = calculate_delay(&config, 17);
857 let at_18 = calculate_delay(&config, 18);
858 let at_25 = calculate_delay(&config, 25);
859 assert_eq!(at_17, at_18);
860 assert_eq!(at_17, at_25);
861 assert_eq!(at_17, Duration::from_millis(65536));
863 }
864
865 #[test]
868 fn test_strategy_selection_produces_distinct_delays() {
869 let base = Duration::from_secs(2);
870 let max = Duration::from_secs(3600);
871 let make = |s| RetryStrategyConfig {
872 strategy: s,
873 base_delay: base,
874 max_delay: max,
875 jitter: 0.0,
876 max_attempts: 10,
877 };
878 let attempt = 3;
879 let imm = calculate_delay(&make(RetryStrategyType::Immediate), attempt);
880 let exp = calculate_delay(&make(RetryStrategyType::Exponential), attempt);
881 let lin = calculate_delay(&make(RetryStrategyType::Linear), attempt);
882 let con = calculate_delay(&make(RetryStrategyType::Constant), attempt);
883
884 assert_eq!(imm, Duration::ZERO);
886 assert_eq!(exp, Duration::from_secs(8));
887 assert_eq!(lin, Duration::from_secs(6));
888 assert_eq!(con, Duration::from_secs(2));
889 let vals = [imm, exp, lin, con];
891 for i in 0..vals.len() {
892 for j in (i + 1)..vals.len() {
893 assert_ne!(vals[i], vals[j], "strategies {} and {} collided", i, j);
894 }
895 }
896 }
897
898 #[test]
899 fn test_immediate_always_zero_regardless_of_config() {
900 let config = RetryStrategyConfig {
901 strategy: RetryStrategyType::Immediate,
902 base_delay: Duration::from_secs(999),
903 max_delay: Duration::from_secs(9999),
904 jitter: 0.0,
905 max_attempts: 50,
906 };
907 for attempt in [1, 5, 10, 50, u32::MAX] {
908 assert_eq!(calculate_delay(&config, attempt), Duration::ZERO);
909 }
910 }
911
912 #[test]
913 fn test_constant_ignores_attempt_number() {
914 let config = RetryStrategyConfig {
915 strategy: RetryStrategyType::Constant,
916 base_delay: Duration::from_millis(750),
917 max_delay: Duration::from_secs(60),
918 jitter: 0.0,
919 max_attempts: 100,
920 };
921 let first = calculate_delay(&config, 1);
922 for attempt in 2..=20 {
923 assert_eq!(
924 calculate_delay(&config, attempt),
925 first,
926 "constant delay should not change with attempt number"
927 );
928 }
929 }
930
931 #[test]
934 fn test_attempt_zero_does_not_panic() {
935 for strategy in [
936 RetryStrategyType::Immediate,
937 RetryStrategyType::Exponential,
938 RetryStrategyType::Linear,
939 RetryStrategyType::Constant,
940 ] {
941 let config = RetryStrategyConfig {
942 strategy,
943 base_delay: Duration::from_secs(1),
944 max_delay: Duration::from_secs(60),
945 jitter: 0.0,
946 max_attempts: 10,
947 };
948 let _ = calculate_delay(&config, 0);
950 }
951 }
952
953 #[test]
954 fn test_executor_max_attempts_one_no_retry() {
955 let executor = RetryExecutor::new(RetryStrategyConfig {
956 strategy: RetryStrategyType::Immediate,
957 max_attempts: 1,
958 base_delay: Duration::ZERO,
959 max_delay: Duration::ZERO,
960 jitter: 0.0,
961 });
962 let mut call_count = 0u32;
963 let result = executor.run(|_| {
964 call_count += 1;
965 Err::<(), _>("fail")
966 });
967 assert_eq!(result, Err("fail"));
968 assert_eq!(call_count, 1, "max_attempts=1 should call exactly once");
969 }
970
971 #[test]
972 fn test_executor_run_with_classification_success() {
973 let executor = RetryExecutor::new(RetryStrategyConfig {
974 strategy: RetryStrategyType::Immediate,
975 max_attempts: 5,
976 base_delay: Duration::ZERO,
977 max_delay: Duration::ZERO,
978 jitter: 0.0,
979 });
980 let result = executor.run_with_classification(|attempt| {
981 if attempt < 3 {
982 Err("transient")
983 } else {
984 Ok(("done", true))
985 }
986 });
987 assert_eq!(result, Ok("done"));
988 }
989
990 #[test]
991 fn test_executor_run_with_classification_exhausted() {
992 let executor = RetryExecutor::new(RetryStrategyConfig {
993 strategy: RetryStrategyType::Immediate,
994 max_attempts: 2,
995 base_delay: Duration::ZERO,
996 max_delay: Duration::ZERO,
997 jitter: 0.0,
998 });
999 let result =
1000 executor.run_with_classification(|_| Err::<(&str, bool), _>("permanent failure"));
1001 assert_eq!(result, Err("permanent failure"));
1002 }
1003
1004 #[test]
1005 fn test_config_for_error_all_three_overrides() {
1006 let default = RetryStrategyConfig::default();
1007 let per_error = PerErrorConfig {
1008 retryable: Some(RetryStrategyConfig {
1009 max_attempts: 10,
1010 ..Default::default()
1011 }),
1012 ambiguous: Some(RetryStrategyConfig {
1013 max_attempts: 20,
1014 ..Default::default()
1015 }),
1016 permanent: Some(RetryStrategyConfig {
1017 max_attempts: 30,
1018 ..Default::default()
1019 }),
1020 };
1021 assert_eq!(
1022 config_for_error(&default, Some(&per_error), ErrorClass::Retryable).max_attempts,
1023 10
1024 );
1025 assert_eq!(
1026 config_for_error(&default, Some(&per_error), ErrorClass::Ambiguous).max_attempts,
1027 20
1028 );
1029 assert_eq!(
1030 config_for_error(&default, Some(&per_error), ErrorClass::Permanent).max_attempts,
1031 30
1032 );
1033 }
1034
1035 #[test]
1036 fn test_default_config_matches_default_policy() {
1037 let from_default = RetryStrategyConfig::default();
1038 let from_policy = RetryPolicy::Default.to_config();
1039 assert_eq!(from_default.strategy, from_policy.strategy);
1040 assert_eq!(from_default.max_attempts, from_policy.max_attempts);
1041 assert_eq!(from_default.base_delay, from_policy.base_delay);
1042 assert_eq!(from_default.max_delay, from_policy.max_delay);
1043 assert!(
1044 (from_default.jitter - from_policy.jitter).abs() < f64::EPSILON,
1045 "jitter should match"
1046 );
1047 }
1048
1049 #[test]
1050 fn test_jitter_bounds_with_exponential() {
1051 let config = RetryStrategyConfig {
1052 strategy: RetryStrategyType::Exponential,
1053 base_delay: Duration::from_secs(1),
1054 max_delay: Duration::from_secs(3600),
1055 jitter: 0.3,
1056 max_attempts: 10,
1057 };
1058 for _ in 0..200 {
1060 let delay = calculate_delay(&config, 3);
1061 assert!(
1062 delay >= Duration::from_millis(2800),
1063 "delay {:?} below lower bound",
1064 delay
1065 );
1066 assert!(
1067 delay <= Duration::from_millis(5200),
1068 "delay {:?} above upper bound",
1069 delay
1070 );
1071 }
1072 }
1073
1074 #[test]
1077 fn test_jitter_zero_is_deterministic_across_strategies() {
1078 for strategy in [
1080 RetryStrategyType::Exponential,
1081 RetryStrategyType::Linear,
1082 RetryStrategyType::Constant,
1083 ] {
1084 let config = RetryStrategyConfig {
1085 strategy,
1086 base_delay: Duration::from_millis(500),
1087 max_delay: Duration::from_secs(120),
1088 jitter: 0.0,
1089 max_attempts: 20,
1090 };
1091 for attempt in 1..=10 {
1092 let a = calculate_delay(&config, attempt);
1093 let b = calculate_delay(&config, attempt);
1094 assert_eq!(a, b, "{:?} attempt {} not deterministic", strategy, attempt);
1095 }
1096 }
1097 }
1098}
1099
1100#[cfg(test)]
1101mod property_tests {
1102 use super::*;
1103 use proptest::prelude::*;
1104
1105 fn expected_exponential(base: Duration, max: Duration, attempt: u32) -> Duration {
1106 let delay = base.saturating_mul(2_u32.saturating_pow(attempt.saturating_sub(1).min(16)));
1107 delay.min(max)
1108 }
1109
1110 fn expected_linear(base: Duration, max: Duration, attempt: u32) -> Duration {
1111 base.saturating_mul(attempt).min(max)
1112 }
1113
1114 proptest! {
1115 #[test]
1116 fn exponential_delay_matches_formula_with_no_jitter(
1117 base_ms in 1u64..10_000,
1118 extra_ms in 0u64..290_000,
1119 attempt in 1u32..40,
1120 ) {
1121 let base_delay = Duration::from_millis(base_ms);
1122 let max_delay = Duration::from_millis(base_ms.saturating_add(extra_ms).min(300_000));
1123
1124 let config = RetryStrategyConfig {
1125 strategy: RetryStrategyType::Exponential,
1126 max_attempts: 100,
1127 base_delay,
1128 max_delay,
1129 jitter: 0.0,
1130 };
1131
1132 let expected = expected_exponential(base_delay, max_delay, attempt);
1133 prop_assert_eq!(calculate_delay(&config, attempt), expected);
1134 prop_assert!(calculate_delay(&config, attempt) <= max_delay);
1135 }
1136
1137 #[test]
1138 fn linear_delay_matches_formula_with_no_jitter(
1139 base_ms in 1u64..5_000,
1140 extra_ms in 0u64..295_000,
1141 attempt in 1u32..60,
1142 ) {
1143 let base_delay = Duration::from_millis(base_ms);
1144 let max_delay = Duration::from_millis(base_ms.saturating_add(extra_ms).min(300_000));
1145
1146 let config = RetryStrategyConfig {
1147 strategy: RetryStrategyType::Linear,
1148 max_attempts: 100,
1149 base_delay,
1150 max_delay,
1151 jitter: 0.0,
1152 };
1153
1154 let expected = expected_linear(base_delay, max_delay, attempt);
1155 prop_assert_eq!(calculate_delay(&config, attempt), expected);
1156 prop_assert!(calculate_delay(&config, attempt) <= max_delay);
1157 }
1158
1159 #[test]
1160 fn constant_and_immediate_hold_edge_invariants(
1161 base_ms in 0u64..20_000,
1162 extra_ms in 0u64..50_000,
1163 jitter_byte in 0u8..=255,
1164 ) {
1165 let base_delay = Duration::from_millis(base_ms);
1166 let max_delay = Duration::from_millis((base_ms + extra_ms).min(300_000));
1167 let jitter = (jitter_byte as f64) / 255.0;
1168
1169 let constant = RetryStrategyConfig {
1170 strategy: RetryStrategyType::Constant,
1171 max_attempts: 100,
1172 base_delay,
1173 max_delay,
1174 jitter,
1175 };
1176 let delay = calculate_delay(&constant, 4);
1177 prop_assert!(delay <= max_delay.saturating_mul(2));
1178
1179 let no_jitter = RetryStrategyConfig {
1180 jitter: 0.0,
1181 ..constant.clone()
1182 };
1183 prop_assert_eq!(calculate_delay(&no_jitter, 5), base_delay.min(max_delay));
1184
1185 let immediate = RetryStrategyConfig {
1186 strategy: RetryStrategyType::Immediate,
1187 ..constant
1188 };
1189 prop_assert_eq!(calculate_delay(&immediate, 9), Duration::ZERO);
1190 }
1191
1192 #[test]
1193 fn backoff_never_exceeds_max_delay(
1194 strategy_idx in 0u8..4,
1195 base_ms in 0u64..100_000,
1196 max_ms in 0u64..300_000,
1197 attempt in 1u32..100,
1198 ) {
1199 let strategy = match strategy_idx {
1200 0 => RetryStrategyType::Immediate,
1201 1 => RetryStrategyType::Exponential,
1202 2 => RetryStrategyType::Linear,
1203 _ => RetryStrategyType::Constant,
1204 };
1205
1206 let max_delay = Duration::from_millis(max_ms);
1207
1208 let config = RetryStrategyConfig {
1209 strategy,
1210 max_attempts: 100,
1211 base_delay: Duration::from_millis(base_ms),
1212 max_delay,
1213 jitter: 0.0,
1214 };
1215
1216 let delay = calculate_delay(&config, attempt);
1217 prop_assert!(
1218 delay <= max_delay,
1219 "strategy={:?} base={}ms max={}ms attempt={} => delay={:?} exceeded max_delay={:?}",
1220 strategy, base_ms, max_ms, attempt, delay, max_delay
1221 );
1222 }
1223
1224 #[test]
1225 fn jitter_delay_within_bounds(
1226 base_ms in 100u64..10_000,
1227 jitter_pct in 1u8..100,
1228 attempt in 1u32..10,
1229 ) {
1230 let jitter = (jitter_pct as f64) / 100.0;
1231 let base_delay = Duration::from_millis(base_ms);
1232 let max_delay = Duration::from_secs(3600);
1233
1234 let config = RetryStrategyConfig {
1235 strategy: RetryStrategyType::Constant,
1236 max_attempts: 100,
1237 base_delay,
1238 max_delay,
1239 jitter,
1240 };
1241
1242 let nominal = base_delay.min(max_delay);
1243 let nominal_ms = nominal.as_millis() as f64;
1244 let eps = 1.0;
1246 let low = Duration::from_millis(((nominal_ms * (1.0 - jitter)) - eps).max(0.0) as u64);
1247 let high = Duration::from_millis((nominal_ms * (1.0 + jitter) + eps).ceil() as u64);
1248
1249 for _ in 0..20 {
1250 let delay = calculate_delay(&config, attempt);
1251 prop_assert!(
1252 delay >= low && delay <= high,
1253 "jitter={} base={}ms => delay={:?} outside [{:?}, {:?}]",
1254 jitter, base_ms, delay, low, high
1255 );
1256 }
1257 }
1258
1259 #[test]
1260 fn exponential_delays_non_decreasing_without_jitter(
1261 base_ms in 1u64..5_000,
1262 max_ms in 1u64..300_000,
1263 ) {
1264 let config = RetryStrategyConfig {
1265 strategy: RetryStrategyType::Exponential,
1266 max_attempts: 100,
1267 base_delay: Duration::from_millis(base_ms),
1268 max_delay: Duration::from_millis(max_ms),
1269 jitter: 0.0,
1270 };
1271
1272 let mut prev = Duration::ZERO;
1273 for attempt in 1..=20 {
1274 let curr = calculate_delay(&config, attempt);
1275 prop_assert!(
1276 curr >= prev,
1277 "delay decreased at attempt {}: {:?} < {:?}",
1278 attempt, curr, prev
1279 );
1280 prev = curr;
1281 }
1282 }
1283
1284 #[test]
1285 fn delays_never_negative_or_panic(
1286 strategy_idx in 0u8..4,
1287 base_ms in 0u64..u64::MAX / 2,
1288 max_ms in 0u64..300_000,
1289 attempt in 0u32..200,
1290 ) {
1291 let strategy = match strategy_idx {
1292 0 => RetryStrategyType::Immediate,
1293 1 => RetryStrategyType::Exponential,
1294 2 => RetryStrategyType::Linear,
1295 _ => RetryStrategyType::Constant,
1296 };
1297
1298 let config = RetryStrategyConfig {
1299 strategy,
1300 max_attempts: 100,
1301 base_delay: Duration::from_millis(base_ms.min(300_000)),
1302 max_delay: Duration::from_millis(max_ms),
1303 jitter: 0.0,
1304 };
1305
1306 let delay = calculate_delay(&config, attempt);
1308 prop_assert!(delay <= Duration::from_millis(max_ms));
1310 }
1311 }
1312}
1313
1314#[cfg(test)]
1315mod snapshot_tests {
1316 use super::*;
1317 use insta::assert_yaml_snapshot;
1318
1319 #[test]
1320 fn snapshot_default_policy_config() {
1321 assert_yaml_snapshot!(RetryPolicy::Default.to_config());
1322 }
1323
1324 #[test]
1325 fn snapshot_aggressive_policy_config() {
1326 assert_yaml_snapshot!(RetryPolicy::Aggressive.to_config());
1327 }
1328
1329 #[test]
1330 fn snapshot_conservative_policy_config() {
1331 assert_yaml_snapshot!(RetryPolicy::Conservative.to_config());
1332 }
1333
1334 #[test]
1335 fn snapshot_custom_policy_config() {
1336 assert_yaml_snapshot!(RetryPolicy::Custom.to_config());
1337 }
1338
1339 #[test]
1340 fn snapshot_error_classes() {
1341 assert_yaml_snapshot!("retryable", ErrorClass::Retryable);
1342 assert_yaml_snapshot!("ambiguous", ErrorClass::Ambiguous);
1343 assert_yaml_snapshot!("permanent", ErrorClass::Permanent);
1344 }
1345
1346 #[test]
1347 fn snapshot_per_error_config_empty() {
1348 assert_yaml_snapshot!(PerErrorConfig::default());
1349 }
1350
1351 #[test]
1352 fn snapshot_per_error_config_with_retryable_override() {
1353 let config = PerErrorConfig {
1354 retryable: Some(RetryStrategyConfig {
1355 strategy: RetryStrategyType::Immediate,
1356 max_attempts: 10,
1357 base_delay: Duration::ZERO,
1358 max_delay: Duration::from_secs(5),
1359 jitter: 0.0,
1360 }),
1361 ambiguous: None,
1362 permanent: None,
1363 };
1364 assert_yaml_snapshot!(config);
1365 }
1366
1367 #[test]
1368 fn snapshot_delay_sequence_exponential() {
1369 let config = RetryStrategyConfig {
1370 strategy: RetryStrategyType::Exponential,
1371 base_delay: Duration::from_secs(1),
1372 max_delay: Duration::from_secs(60),
1373 jitter: 0.0,
1374 max_attempts: 10,
1375 };
1376 let delays: Vec<String> = (1..=7)
1377 .map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
1378 .collect();
1379 assert_yaml_snapshot!(delays);
1380 }
1381
1382 #[test]
1383 fn snapshot_delay_sequence_linear() {
1384 let config = RetryStrategyConfig {
1385 strategy: RetryStrategyType::Linear,
1386 base_delay: Duration::from_secs(2),
1387 max_delay: Duration::from_secs(20),
1388 jitter: 0.0,
1389 max_attempts: 10,
1390 };
1391 let delays: Vec<String> = (1..=10)
1392 .map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
1393 .collect();
1394 assert_yaml_snapshot!(delays);
1395 }
1396
1397 #[test]
1398 fn snapshot_strategy_types() {
1399 assert_yaml_snapshot!("immediate", RetryStrategyType::Immediate);
1400 assert_yaml_snapshot!("exponential", RetryStrategyType::Exponential);
1401 assert_yaml_snapshot!("linear", RetryStrategyType::Linear);
1402 assert_yaml_snapshot!("constant", RetryStrategyType::Constant);
1403 }
1404
1405 #[test]
1406 fn snapshot_debug_default_config() {
1407 insta::assert_debug_snapshot!(RetryStrategyConfig::default());
1408 }
1409
1410 #[test]
1411 fn snapshot_debug_all_policies() {
1412 insta::assert_debug_snapshot!("debug_default", RetryPolicy::Default.to_config());
1413 insta::assert_debug_snapshot!("debug_aggressive", RetryPolicy::Aggressive.to_config());
1414 insta::assert_debug_snapshot!("debug_conservative", RetryPolicy::Conservative.to_config());
1415 }
1416
1417 #[test]
1418 fn snapshot_debug_retry_policy_variants() {
1419 insta::assert_debug_snapshot!(vec![
1420 RetryPolicy::Default,
1421 RetryPolicy::Aggressive,
1422 RetryPolicy::Conservative,
1423 RetryPolicy::Custom,
1424 ]);
1425 }
1426
1427 #[test]
1428 fn snapshot_delay_sequence_constant() {
1429 let config = RetryStrategyConfig {
1430 strategy: RetryStrategyType::Constant,
1431 base_delay: Duration::from_millis(500),
1432 max_delay: Duration::from_secs(60),
1433 jitter: 0.0,
1434 max_attempts: 10,
1435 };
1436 let delays: Vec<String> = (1..=8)
1437 .map(|a| format!("attempt {}: {:?}", a, calculate_delay(&config, a)))
1438 .collect();
1439 assert_yaml_snapshot!(delays);
1440 }
1441
1442 #[test]
1443 fn snapshot_all_strategies_at_attempt_5() {
1444 let make = |s| RetryStrategyConfig {
1445 strategy: s,
1446 base_delay: Duration::from_secs(1),
1447 max_delay: Duration::from_secs(120),
1448 jitter: 0.0,
1449 max_attempts: 10,
1450 };
1451 let result: Vec<String> = [
1452 RetryStrategyType::Immediate,
1453 RetryStrategyType::Exponential,
1454 RetryStrategyType::Linear,
1455 RetryStrategyType::Constant,
1456 ]
1457 .iter()
1458 .map(|&s| format!("{:?}: {:?}", s, calculate_delay(&make(s), 5)))
1459 .collect();
1460 assert_yaml_snapshot!(result);
1461 }
1462}