1#![expect(
2 clippy::cast_possible_truncation,
3 clippy::cast_possible_wrap,
4 reason = "Retry exponents and jitter are clamped to the supported retry range before conversion."
5)]
6
7use std::time::Duration;
21
22use crate::error_category::{ErrorCategory, classify_anyhow_error};
23
24#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
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 fn new(max_attempts: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
39 Self {
40 max_attempts: if max_attempts < 1 { 1 } else { max_attempts },
41 initial_delay,
42 max_delay,
43 multiplier: if multiplier < 1.0 { 1.0 } else { multiplier },
44 jitter: 0.0,
45 }
46 }
47
48 pub const fn from_retries(max_retries: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
49 Self::new(max_retries.saturating_add(1), initial_delay, max_delay, multiplier)
50 }
51
52 fn simple(max_retries: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
58 Self::from_retries(max_retries, Duration::from_millis(base_delay_ms), Duration::from_millis(max_delay_ms), 2.0)
59 }
60
61 pub fn delay_for_attempt(&self, attempt_index: u32) -> Duration {
62 let multiplier = self.multiplier.powi(attempt_index as i32);
63 let base_delay = Duration::try_from_secs_f64(self.initial_delay.as_secs_f64() * multiplier)
64 .unwrap_or(self.max_delay)
65 .min(self.max_delay);
66
67 if !self.jitter.is_finite() || self.jitter <= 0.0 {
68 return base_delay;
69 }
70
71 #[allow(
72 clippy::cast_sign_loss,
73 reason = "Intentional compatibility, platform, or test-only suppression."
74 )]
75 let max_jitter_ms = (base_delay.as_millis() as f64 * self.jitter)
76 .round()
77 .clamp(0.0, u64::MAX as f64) as u64;
78 if max_jitter_ms == 0 {
79 return base_delay;
80 }
81
82 let offset = (u64::from(attempt_index) * 31) % max_jitter_ms.saturating_add(1);
83 base_delay.saturating_add(Duration::from_millis(offset))
84 }
85
86 pub fn decision_for_category(
87 &self,
88 category: ErrorCategory,
89 attempt_index: u32,
90 retry_after: Option<Duration>,
91 ) -> RetryDecision {
92 let has_remaining_attempts = attempt_index.saturating_add(1) < self.max_attempts;
93 if !category.is_retryable() || !has_remaining_attempts {
94 return RetryDecision {
95 category,
96 retryable: false,
97 delay: None,
98 retry_after,
99 };
100 }
101
102 let delay = retry_after.unwrap_or_else(|| self.delay_for_attempt(attempt_index));
103 RetryDecision {
104 category,
105 retryable: true,
106 delay: Some(delay),
107 retry_after,
108 }
109 }
110
111 pub fn classify_anyhow(&self, error: &anyhow::Error) -> RetryDecision {
118 let category = classify_anyhow_error(error);
119 RetryDecision {
120 category,
121 retryable: category.is_retryable(),
122 delay: None,
123 retry_after: None,
124 }
125 }
126
127 pub fn classify_status(&self, status: u16) -> RetryDecision {
131 let category = match status {
132 429 => ErrorCategory::RateLimit,
133 500 | 502 | 504 => ErrorCategory::Network,
134 503 => ErrorCategory::ServiceUnavailable,
135 401 | 403 => ErrorCategory::Authentication,
136 _ => ErrorCategory::ExecutionError,
137 };
138 RetryDecision {
139 category,
140 retryable: category.is_retryable(),
141 delay: None,
142 retry_after: None,
143 }
144 }
145}
146
147impl Default for RetryPolicy {
148 fn default() -> Self {
149 Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0)
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct RetryDecision {
156 pub category: ErrorCategory,
157 pub retryable: bool,
158 pub delay: Option<Duration>,
159 pub retry_after: Option<Duration>,
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn default_policy_allows_two_retries() {
168 let policy = RetryPolicy::default();
169 assert_eq!(policy.max_attempts, 3);
170 assert_eq!(policy.initial_delay, Duration::from_secs(1));
171 assert_eq!(policy.max_delay, Duration::from_secs(60));
172 }
173
174 #[test]
175 fn classify_status_rate_limit() {
176 let policy = RetryPolicy::default();
177 let decision = policy.classify_status(429);
178 assert!(decision.retryable);
179 assert_eq!(decision.category, ErrorCategory::RateLimit);
180 }
181
182 #[test]
183 fn classify_status_server_error() {
184 let policy = RetryPolicy::default();
185 let decision = policy.classify_status(503);
186 assert!(decision.retryable);
187 assert_eq!(decision.category, ErrorCategory::ServiceUnavailable);
188 }
189
190 #[test]
191 fn classify_status_auth_not_retryable() {
192 let policy = RetryPolicy::default();
193 let decision = policy.classify_status(401);
194 assert!(!decision.retryable);
195 assert_eq!(decision.category, ErrorCategory::Authentication);
196 }
197
198 #[test]
199 fn classify_anyhow_network_error() {
200 let policy = RetryPolicy::default();
201 let err = anyhow::anyhow!("connection refused");
202 let decision = policy.classify_anyhow(&err);
203 assert!(decision.retryable);
204 }
205
206 #[test]
207 fn simple_policy_matches_bit_shift_doubling() {
208 let policy = RetryPolicy::simple(10, 1000, 5000);
211 let legacy = |attempt: u32| -> u64 { 1000u64.saturating_mul(1u64 << attempt.min(16)).min(5000) };
212 for attempt in 0..6 {
213 assert_eq!(
214 policy.delay_for_attempt(attempt),
215 Duration::from_millis(legacy(attempt)),
216 "delay mismatch at attempt {attempt}"
217 );
218 }
219 }
220
221 #[test]
222 fn delay_for_attempt_clamps_overflowing_backoff_to_max_delay() {
223 let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), f64::MAX);
224
225 assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(8));
226 }
227
228 #[test]
229 fn delay_for_attempt_ignores_non_finite_jitter() {
230 let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
231 policy.jitter = f64::INFINITY;
232
233 assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
234 }
235
236 #[test]
237 fn delay_for_attempt_handles_huge_finite_jitter() {
238 let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
239 policy.jitter = f64::MAX;
240
241 assert!(policy.delay_for_attempt(1) >= Duration::from_secs(2));
242 }
243
244 #[test]
245 fn decision_for_category_respects_attempt_budget() {
246 let policy = RetryPolicy::from_retries(1, Duration::from_secs(1), Duration::from_secs(8), 2.0);
247
248 let first = policy.decision_for_category(ErrorCategory::Network, 0, None);
249 assert!(first.retryable);
250 assert_eq!(first.delay, Some(Duration::from_secs(1)));
251
252 let exhausted = policy.decision_for_category(ErrorCategory::Network, 1, None);
253 assert!(!exhausted.retryable);
254 assert!(exhausted.delay.is_none());
255 }
256
257 #[test]
258 fn decision_for_category_prefers_retry_after() {
259 let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
260
261 let decision = policy.decision_for_category(ErrorCategory::RateLimit, 0, Some(Duration::from_secs(7)));
262 assert!(decision.retryable);
263 assert_eq!(decision.delay, Some(Duration::from_secs(7)));
264 assert_eq!(decision.retry_after, Some(Duration::from_secs(7)));
265 }
266}