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