1#![expect(
2 clippy::cast_possible_truncation,
3 reason = "Retry exponents and jitter are clamped to the supported retry range before conversion."
4)]
5
6use std::time::Duration;
20
21use crate::error_category::{ErrorCategory, classify_anyhow_error};
22
23#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
25#[must_use]
26pub struct RetryPolicy {
27 pub max_attempts: u32,
29 pub initial_delay: Duration,
30 pub max_delay: Duration,
31 pub multiplier: f64,
32 pub jitter: f64,
33}
34
35impl RetryPolicy {
36 pub const DEFAULT: Self = Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0);
37
38 pub const DEFAULT_JITTER: f64 = 0.15;
42
43 pub fn with_jitter(mut self, jitter: f64) -> Self {
45 self.jitter = jitter;
46 self
47 }
48
49 pub const fn new(max_attempts: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
50 Self {
51 max_attempts: if max_attempts < 1 { 1 } else { max_attempts },
52 initial_delay,
53 max_delay,
54 multiplier: if multiplier < 1.0 { 1.0 } else { multiplier },
55 jitter: 0.0,
56 }
57 }
58
59 pub const fn from_retries(max_retries: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
60 Self::new(max_retries.saturating_add(1), initial_delay, max_delay, multiplier)
61 }
62
63 fn simple(max_retries: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
69 Self::from_retries(max_retries, Duration::from_millis(base_delay_ms), Duration::from_millis(max_delay_ms), 2.0)
70 }
71
72 #[must_use]
73 pub fn delay_for_attempt(&self, attempt_index: u32) -> Duration {
74 let exponent = i32::try_from(attempt_index).unwrap_or(i32::MAX);
77 let multiplier = self.multiplier.powi(exponent);
78 let base_delay = Duration::try_from_secs_f64(self.initial_delay.as_secs_f64() * multiplier)
79 .unwrap_or(self.max_delay)
80 .min(self.max_delay);
81
82 if !self.jitter.is_finite() || self.jitter <= 0.0 {
83 return base_delay;
84 }
85
86 #[allow(
87 clippy::cast_sign_loss,
88 reason = "Intentional compatibility, platform, or test-only suppression."
89 )]
90 let max_jitter_ms = (base_delay.as_millis() as f64 * self.jitter)
91 .round()
92 .clamp(0.0, u64::MAX as f64) as u64;
93 if max_jitter_ms == 0 {
94 return base_delay;
95 }
96
97 let offset = (u64::from(attempt_index) * 31) % max_jitter_ms.saturating_add(1);
98 base_delay.saturating_add(Duration::from_millis(offset))
99 }
100
101 pub fn decision_for_category(
102 &self,
103 category: ErrorCategory,
104 attempt_index: u32,
105 retry_after: Option<Duration>,
106 ) -> RetryDecision {
107 let has_remaining_attempts = attempt_index.saturating_add(1) < self.max_attempts;
108 if !category.is_retryable() || !has_remaining_attempts {
109 return RetryDecision {
110 category,
111 retryable: false,
112 delay: None,
113 retry_after,
114 };
115 }
116
117 let delay = retry_after.unwrap_or_else(|| self.delay_for_attempt(attempt_index));
118 RetryDecision {
119 category,
120 retryable: true,
121 delay: Some(delay),
122 retry_after,
123 }
124 }
125
126 pub fn classify_anyhow(&self, error: &anyhow::Error) -> RetryDecision {
133 let category = classify_anyhow_error(error);
134 RetryDecision {
135 category,
136 retryable: category.is_retryable(),
137 delay: None,
138 retry_after: None,
139 }
140 }
141
142 pub fn classify_status(&self, status: u16) -> RetryDecision {
146 let category = match status {
147 429 => ErrorCategory::RateLimit,
148 500 | 502 | 504 => ErrorCategory::Network,
149 503 => ErrorCategory::ServiceUnavailable,
150 401 | 403 => ErrorCategory::Authentication,
151 _ => ErrorCategory::ExecutionError,
152 };
153 RetryDecision {
154 category,
155 retryable: category.is_retryable(),
156 delay: None,
157 retry_after: None,
158 }
159 }
160}
161
162impl Default for RetryPolicy {
163 fn default() -> Self {
164 Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0)
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170#[must_use = "retry decisions must be handled before another attempt"]
171pub struct RetryDecision {
172 pub category: ErrorCategory,
173 pub retryable: bool,
174 pub delay: Option<Duration>,
175 pub retry_after: Option<Duration>,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn default_policy_allows_two_retries() {
184 let policy = RetryPolicy::default();
185 assert_eq!(policy.max_attempts, 3);
186 assert_eq!(policy.initial_delay, Duration::from_secs(1));
187 assert_eq!(policy.max_delay, Duration::from_secs(60));
188 }
189
190 #[test]
191 fn classify_status_rate_limit() {
192 let policy = RetryPolicy::default();
193 let decision = policy.classify_status(429);
194 assert!(decision.retryable);
195 assert_eq!(decision.category, ErrorCategory::RateLimit);
196 }
197
198 #[test]
199 fn classify_status_server_error() {
200 let policy = RetryPolicy::default();
201 let decision = policy.classify_status(503);
202 assert!(decision.retryable);
203 assert_eq!(decision.category, ErrorCategory::ServiceUnavailable);
204 }
205
206 #[test]
207 fn classify_status_auth_not_retryable() {
208 let policy = RetryPolicy::default();
209 let decision = policy.classify_status(401);
210 assert!(!decision.retryable);
211 assert_eq!(decision.category, ErrorCategory::Authentication);
212 }
213
214 #[test]
215 fn classify_status_matrix_pins_transport_retry_boundaries() {
216 let policy = RetryPolicy::default();
220 for status in [429, 500, 502, 503, 504] {
221 assert!(policy.classify_status(status).retryable, "HTTP {status} must retry");
222 }
223 for status in [400, 401, 403, 404, 422] {
224 assert!(!policy.classify_status(status).retryable, "HTTP {status} must fail fast");
225 }
226 }
227
228 #[test]
229 fn classify_anyhow_network_error() {
230 let policy = RetryPolicy::default();
231 let err = anyhow::anyhow!("connection refused");
232 let decision = policy.classify_anyhow(&err);
233 assert!(decision.retryable);
234 }
235
236 #[test]
237 fn simple_policy_matches_bit_shift_doubling() {
238 let policy = RetryPolicy::simple(10, 1000, 5000);
241 let legacy = |attempt: u32| -> u64 { 1000u64.saturating_mul(1u64 << attempt.min(16)).min(5000) };
242 for attempt in 0..6 {
243 assert_eq!(
244 policy.delay_for_attempt(attempt),
245 Duration::from_millis(legacy(attempt)),
246 "delay mismatch at attempt {attempt}"
247 );
248 }
249 }
250
251 #[test]
252 fn delay_for_attempt_clamps_overflowing_backoff_to_max_delay() {
253 let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), f64::MAX);
254
255 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(8));
256 }
257
258 #[test]
259 fn delay_for_attempt_ignores_non_finite_jitter() {
260 let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
261 policy.jitter = f64::INFINITY;
262
263 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
264 }
265
266 #[test]
267 fn delay_for_attempt_handles_huge_finite_jitter() {
268 let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
269 policy.jitter = f64::MAX;
270
271 assert!(policy.delay_for_attempt(1) >= Duration::from_secs(2));
272 }
273
274 #[test]
275 fn decision_for_category_respects_attempt_budget() {
276 let policy = RetryPolicy::from_retries(1, Duration::from_secs(1), Duration::from_secs(8), 2.0);
277
278 let first = policy.decision_for_category(ErrorCategory::Network, 0, None);
279 assert!(first.retryable);
280 assert_eq!(first.delay, Some(Duration::from_secs(1)));
281
282 let exhausted = policy.decision_for_category(ErrorCategory::Network, 1, None);
283 assert!(!exhausted.retryable);
284 assert!(exhausted.delay.is_none());
285 }
286
287 #[test]
288 fn decision_for_category_prefers_retry_after() {
289 let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
290
291 let decision = policy.decision_for_category(ErrorCategory::RateLimit, 0, Some(Duration::from_secs(7)));
292 assert!(decision.retryable);
293 assert_eq!(decision.delay, Some(Duration::from_secs(7)));
294 assert_eq!(decision.retry_after, Some(Duration::from_secs(7)));
295 }
296
297 #[test]
298 fn with_jitter_applies_a_deterministic_offset() {
299 let plain = RetryPolicy::from_retries(3, Duration::from_millis(1000), Duration::from_secs(30), 2.0);
300 let jittered = plain.with_jitter(RetryPolicy::DEFAULT_JITTER);
301 assert_eq!(jittered.delay_for_attempt(0), Duration::from_millis(1000));
304 assert_eq!(jittered.delay_for_attempt(1), Duration::from_millis(2031));
305 assert_eq!(plain.delay_for_attempt(1), Duration::from_millis(2000));
306 assert_eq!(jittered.delay_for_attempt(1), jittered.delay_for_attempt(1));
308 }
309
310 #[test]
311 fn extreme_attempts_saturate_at_the_cap() {
312 let policy = RetryPolicy::from_retries(3, Duration::from_millis(1000), Duration::from_millis(5000), 2.0)
313 .with_jitter(RetryPolicy::DEFAULT_JITTER);
314 for attempt in [100, 10_000, u32::MAX] {
317 let delay = policy.delay_for_attempt(attempt);
318 assert!(
319 (Duration::from_millis(5000)..=Duration::from_millis(5750)).contains(&delay),
320 "attempt {attempt} escaped the cap: {delay:?}"
321 );
322 }
323 }
324}