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 seed = std::time::SystemTime::now()
76 .duration_since(std::time::UNIX_EPOCH)
77 .unwrap_or_default()
78 .subsec_nanos();
79 self.backoff_with_seed(attempt, seed)
80 }
81
82 fn backoff_with_seed(&self, attempt: u32, seed_nanos: u32) -> Duration {
89 const NANOS_PER_SEC: f64 = 1_000_000_000.0;
90
91 let base_ms = self.initial_backoff.as_millis() as f64;
92 let exp = 2_f64.powi(attempt.saturating_sub(1) as i32);
93 let backoff_ms = (base_ms * exp).min(self.max_backoff.as_millis() as f64);
94
95 let jitter_range_ms = backoff_ms * self.jitter_factor.clamp(0.0, 1.0);
96 let fraction = (seed_nanos as f64 / NANOS_PER_SEC).clamp(0.0, 1.0);
97
98 Duration::from_millis((backoff_ms + fraction * jitter_range_ms) as u64)
99 }
100}
101
102pub(crate) fn is_retryable_status(status: u16) -> bool {
104 matches!(status, 429 | 500 | 502 | 503 | 504)
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn default_config_has_reasonable_values() {
113 let cfg = RetryConfig::default();
114 assert_eq!(cfg.max_retries, 3);
115 assert_eq!(cfg.initial_backoff, Duration::from_millis(200));
116 assert_eq!(cfg.max_backoff, Duration::from_secs(10));
117 assert!((cfg.jitter_factor - 0.25).abs() < f64::EPSILON);
118 }
119
120 #[test]
121 fn none_config_disables_retries() {
122 assert_eq!(RetryConfig::none().max_retries, 0);
123 }
124
125 #[test]
126 fn backoff_grows_exponentially() {
127 let cfg = RetryConfig {
128 max_retries: 5,
129 initial_backoff: Duration::from_millis(100),
130 max_backoff: Duration::from_secs(60),
131 jitter_factor: 0.0,
132 };
133 assert_eq!(cfg.backoff_for(1), Duration::from_millis(100));
134 assert_eq!(cfg.backoff_for(2), Duration::from_millis(200));
135 assert_eq!(cfg.backoff_for(3), Duration::from_millis(400));
136 assert_eq!(cfg.backoff_for(4), Duration::from_millis(800));
137 }
138
139 #[test]
140 fn backoff_caps_at_max() {
141 let cfg = RetryConfig {
142 max_retries: 10,
143 initial_backoff: Duration::from_millis(1000),
144 max_backoff: Duration::from_secs(5),
145 jitter_factor: 0.0,
146 };
147 assert_eq!(cfg.backoff_for(10), Duration::from_secs(5));
148 }
149
150 #[test]
151 fn backoff_with_jitter_is_within_expected_range() {
152 let cfg = RetryConfig {
153 max_retries: 3,
154 initial_backoff: Duration::from_millis(100),
155 max_backoff: Duration::from_secs(60),
156 jitter_factor: 0.25,
157 };
158 let b = cfg.backoff_for(1);
159 assert!(b >= Duration::from_millis(100));
161 assert!(b <= Duration::from_millis(125));
162 }
163
164 #[test]
165 fn jitter_spans_the_full_configured_range() {
166 let cfg = RetryConfig {
167 max_retries: 3,
168 initial_backoff: Duration::from_millis(1000),
169 max_backoff: Duration::from_secs(60),
170 jitter_factor: 0.5,
171 };
172
173 assert_eq!(cfg.backoff_with_seed(1, 0), Duration::from_millis(1000));
177 assert_eq!(
178 cfg.backoff_with_seed(1, 999_999_999),
179 Duration::from_millis(1499)
180 );
181 assert_eq!(
182 cfg.backoff_with_seed(1, 500_000_000),
183 Duration::from_millis(1250)
184 );
185 }
186
187 #[test]
188 fn jitter_never_exceeds_the_configured_factor() {
189 let cfg = RetryConfig {
190 max_retries: 3,
191 initial_backoff: Duration::from_millis(200),
192 max_backoff: Duration::from_secs(60),
193 jitter_factor: 0.25,
194 };
195 for seed in [0, 1, 250_000_000, 999_999_999, u32::MAX] {
196 let b = cfg.backoff_with_seed(1, seed);
197 assert!(b >= Duration::from_millis(200), "seed {seed} underflowed");
198 assert!(b <= Duration::from_millis(250), "seed {seed} overflowed");
199 }
200 }
201
202 #[test]
203 fn retryable_status_codes() {
204 assert!(is_retryable_status(429));
205 assert!(is_retryable_status(500));
206 assert!(is_retryable_status(502));
207 assert!(is_retryable_status(503));
208 assert!(is_retryable_status(504));
209 }
210
211 #[test]
212 fn non_retryable_status_codes() {
213 assert!(!is_retryable_status(200));
214 assert!(!is_retryable_status(400));
215 assert!(!is_retryable_status(401));
216 assert!(!is_retryable_status(403));
217 assert!(!is_retryable_status(404));
218 }
219}