1use std::time::Duration;
2
3#[derive(Clone, Debug, PartialEq)]
24pub struct RetryConfig {
25 pub max_retries: u32,
27
28 pub initial_backoff: Duration,
30
31 pub max_backoff: Duration,
33
34 pub jitter_factor: f64,
37}
38
39impl Default for RetryConfig {
40 fn default() -> Self {
41 Self {
42 max_retries: 3,
43 initial_backoff: Duration::from_millis(200),
44 max_backoff: Duration::from_secs(10),
45 jitter_factor: 0.25,
46 }
47 }
48}
49
50impl RetryConfig {
51 pub fn none() -> Self {
53 Self {
54 max_retries: 0,
55 ..Default::default()
56 }
57 }
58
59 pub fn aggressive() -> Self {
61 Self {
62 max_retries: 5,
63 initial_backoff: Duration::from_millis(100),
64 max_backoff: Duration::from_secs(30),
65 jitter_factor: 0.3,
66 }
67 }
68
69 pub(crate) fn backoff_for(&self, attempt: u32) -> Duration {
74 let base_ms = self.initial_backoff.as_millis() as f64;
75 let exp = 2_f64.powi(attempt.saturating_sub(1) as i32);
76 let backoff_ms = (base_ms * exp).min(self.max_backoff.as_millis() as f64);
77
78 let jitter_range_ms = backoff_ms * self.jitter_factor.clamp(0.0, 1.0);
80 let seed = std::time::SystemTime::now()
81 .duration_since(std::time::UNIX_EPOCH)
82 .unwrap_or_default()
83 .subsec_nanos();
84 let jitter_ms = (seed as f64 / u32::MAX as f64) * jitter_range_ms;
85
86 Duration::from_millis((backoff_ms + jitter_ms) as u64)
87 }
88}
89
90pub(crate) fn is_retryable_status(status: u16) -> bool {
92 matches!(status, 429 | 500 | 502 | 503 | 504)
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn default_config_has_reasonable_values() {
101 let cfg = RetryConfig::default();
102 assert_eq!(cfg.max_retries, 3);
103 assert_eq!(cfg.initial_backoff, Duration::from_millis(200));
104 assert_eq!(cfg.max_backoff, Duration::from_secs(10));
105 assert!((cfg.jitter_factor - 0.25).abs() < f64::EPSILON);
106 }
107
108 #[test]
109 fn none_config_disables_retries() {
110 assert_eq!(RetryConfig::none().max_retries, 0);
111 }
112
113 #[test]
114 fn backoff_grows_exponentially() {
115 let cfg = RetryConfig {
116 max_retries: 5,
117 initial_backoff: Duration::from_millis(100),
118 max_backoff: Duration::from_secs(60),
119 jitter_factor: 0.0,
120 };
121 assert_eq!(cfg.backoff_for(1), Duration::from_millis(100));
122 assert_eq!(cfg.backoff_for(2), Duration::from_millis(200));
123 assert_eq!(cfg.backoff_for(3), Duration::from_millis(400));
124 assert_eq!(cfg.backoff_for(4), Duration::from_millis(800));
125 }
126
127 #[test]
128 fn backoff_caps_at_max() {
129 let cfg = RetryConfig {
130 max_retries: 10,
131 initial_backoff: Duration::from_millis(1000),
132 max_backoff: Duration::from_secs(5),
133 jitter_factor: 0.0,
134 };
135 assert_eq!(cfg.backoff_for(10), Duration::from_secs(5));
136 }
137
138 #[test]
139 fn backoff_with_jitter_is_within_expected_range() {
140 let cfg = RetryConfig {
141 max_retries: 3,
142 initial_backoff: Duration::from_millis(100),
143 max_backoff: Duration::from_secs(60),
144 jitter_factor: 0.25,
145 };
146 let b = cfg.backoff_for(1);
147 assert!(b >= Duration::from_millis(100));
149 assert!(b <= Duration::from_millis(125));
150 }
151
152 #[test]
153 fn retryable_status_codes() {
154 assert!(is_retryable_status(429));
155 assert!(is_retryable_status(500));
156 assert!(is_retryable_status(502));
157 assert!(is_retryable_status(503));
158 assert!(is_retryable_status(504));
159 }
160
161 #[test]
162 fn non_retryable_status_codes() {
163 assert!(!is_retryable_status(200));
164 assert!(!is_retryable_status(400));
165 assert!(!is_retryable_status(401));
166 assert!(!is_retryable_status(403));
167 assert!(!is_retryable_status(404));
168 }
169}