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