aria2_core/engine/
retry_policy.rs1use std::collections::HashSet;
2use std::time::Duration;
3
4use crate::constants;
5use crate::error::Aria2Error;
6
7#[derive(Debug, Clone)]
8pub struct RetryPolicy {
9 pub max_retries: u32,
10 pub base_wait_ms: u64,
11 pub max_wait_ms: u64,
12 pub backoff_factor: f64,
13 pub retryable_http_codes: HashSet<u16>,
14 pub max_retries_per_server: u32,
15}
16
17impl Default for RetryPolicy {
18 fn default() -> Self {
19 let codes: HashSet<u16> = constants::RETRYABLE_HTTP_CODES.iter().copied().collect();
20 Self {
21 max_retries: constants::DEFAULT_MAX_RETRIES,
22 base_wait_ms: 1000,
23 max_wait_ms: 30000,
24 backoff_factor: 2.0,
25 retryable_http_codes: codes,
26 max_retries_per_server: u32::MAX,
27 }
28 }
29}
30
31impl RetryPolicy {
32 #[allow(clippy::field_reassign_with_default)]
33 pub fn new(max_retries: u32, base_wait_ms: u64) -> Self {
34 let mut policy = Self::default();
35 policy.max_retries = max_retries;
36 policy.base_wait_ms = base_wait_ms;
37 policy
38 }
39
40 pub fn with_max_retries(mut self, max_retries: u32) -> Self {
41 self.max_retries = max_retries;
42 self
43 }
44
45 pub fn with_max_per_server(mut self, n: u32) -> Self {
46 self.max_retries_per_server = n;
47 self
48 }
49
50 pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
51 self.max_wait_ms = ms;
52 self
53 }
54
55 pub fn max_tries(&self) -> u32 {
57 self.max_retries
58 }
59
60 pub fn compute_wait(&self, attempt: u32) -> Option<Duration> {
61 if attempt == 0 {
62 return None;
63 }
64 let raw = (self.base_wait_ms as f64) * self.backoff_factor.powi(attempt as i32 - 1);
65 let ms = raw.min(self.max_wait_ms as f64) as u64;
66 Some(Duration::from_millis(ms))
67 }
68
69 pub fn wait_duration(&self, attempt: u32) -> Duration {
74 let base = Duration::from_millis(self.base_wait_ms);
75 let max = Duration::from_millis(self.max_wait_ms);
76 let secs = base.as_secs().saturating_mul(1 << attempt.min(20));
77 let dur = Duration::from_secs(secs);
78 if dur > max {
79 max
80 } else if dur < base {
81 base
82 } else {
83 dur
84 }
85 }
86
87 pub fn should_retry(&self, attempt: u32, error: &Aria2Error) -> bool {
92 if attempt + 1 >= self.max_retries {
93 return false;
94 }
95 matches!(error, Aria2Error::Recoverable(_))
96 }
97
98 pub fn should_retry_http(&self, status_code: u16) -> bool {
99 self.retryable_http_codes.contains(&status_code)
100 }
101
102 pub fn should_retry_error(&self, error_str: &str) -> bool {
103 let lower = error_str.to_lowercase();
104 lower.contains("timeout")
105 || lower.contains("connection reset")
106 || lower.contains("connection refused")
107 || lower.contains("broken pipe")
108 || lower.contains("timed out")
109 || lower.contains("eof")
110 || lower.contains("network")
111 || lower.contains("dns")
112 || lower.contains("socket")
113 || lower.contains("unreachable")
114 || lower.contains("reset by peer")
115 || lower.contains("temporary")
116 || lower.contains("try again")
117 }
118
119 pub fn is_exhausted(&self, attempts: u32) -> bool {
120 attempts > self.max_retries
121 }
122
123 pub fn total_estimated_wait_sec(&self) -> f64 {
124 let mut total = 0.0f64;
125 for a in 1..=self.max_retries {
126 total += (self.base_wait_ms as f64) * self.backoff_factor.powi(a as i32 - 1);
127 }
128 total / 1000.0
129 }
130
131 pub fn stats(&self) -> RetryPolicyStats {
132 RetryPolicyStats {
133 max_retries: self.max_retries,
134 retryable_codes_count: self.retryable_http_codes.len(),
135 estimated_max_total_wait_sec: self.total_estimated_wait_sec(),
136 }
137 }
138}
139
140pub struct RetryPolicyStats {
141 pub max_retries: u32,
142 pub retryable_codes_count: usize,
143 pub estimated_max_total_wait_sec: f64,
144}
145
146#[derive(Debug, Clone)]
147pub struct AttemptRecord {
148 pub attempt_number: u32,
149 pub started_at: std::time::Instant,
150 pub error: Option<String>,
151 pub duration: Duration,
152}
153
154impl AttemptRecord {
155 pub fn new(attempt_number: u32) -> Self {
156 Self {
157 attempt_number,
158 started_at: std::time::Instant::now(),
159 error: None,
160 duration: Duration::ZERO,
161 }
162 }
163
164 pub fn finish(mut self, error: Option<String>) -> Self {
165 self.duration = self.started_at.elapsed();
166 self.error = error;
167 self
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_default_policy_values() {
177 let p = RetryPolicy::default();
178 assert_eq!(p.max_retries, 3);
179 assert_eq!(p.base_wait_ms, 1000);
180 assert_eq!(p.max_wait_ms, 30000);
181 assert!((p.backoff_factor - 2.0).abs() < 0.001);
182 assert!(p.retryable_http_codes.contains(&408));
183 assert!(p.retryable_http_codes.contains(&503));
184 assert_eq!(p.retryable_http_codes.len(), 6);
185 }
186
187 #[test]
188 fn test_compute_wait_exponential() {
189 let p = RetryPolicy::default();
190 assert_eq!(p.compute_wait(0), None);
191 assert_eq!(p.compute_wait(1), Some(Duration::from_millis(1000)));
192 assert_eq!(p.compute_wait(2), Some(Duration::from_millis(2000)));
193 assert_eq!(p.compute_wait(3), Some(Duration::from_millis(4000)));
194 assert_eq!(p.compute_wait(4), Some(Duration::from_millis(8000)));
195 }
196
197 #[test]
198 fn test_compute_wait_capped_at_max() {
199 let p = RetryPolicy::default();
200 let w5 = p.compute_wait(5).unwrap().as_millis();
201 assert!(
202 w5 <= p.max_wait_ms as u128,
203 "wait={} should be capped at {}",
204 w5,
205 p.max_wait_ms
206 );
207 }
208
209 #[test]
210 fn test_compute_wait_zero_attempts() {
211 let p = RetryPolicy::default();
212 assert_eq!(p.compute_wait(0), None, "attempt 0 should not wait");
213 }
214
215 #[test]
216 fn test_should_retry_http_408_true() {
217 let p = RetryPolicy::default();
218 assert!(
219 p.should_retry_http(408),
220 "Request Timeout should be retryable"
221 );
222 assert!(
223 p.should_retry_http(429),
224 "Too Many Requests should be retryable"
225 );
226 assert!(
227 p.should_retry_http(500),
228 "Internal Server Error should be retryable"
229 );
230 assert!(
231 p.should_retry_http(503),
232 "Service Unavailable should be retryable"
233 );
234 }
235
236 #[test]
237 fn test_should_retry_http_404_false() {
238 let p = RetryPolicy::default();
239 assert!(
240 !p.should_retry_http(404),
241 "Not Found should NOT be retryable"
242 );
243 assert!(
244 !p.should_retry_http(403),
245 "Forbidden should NOT be retryable"
246 );
247 assert!(
248 !p.should_retry_http(400),
249 "Bad Request should NOT be retryable"
250 );
251 }
252
253 #[test]
254 fn test_should_retry_network_error_true() {
255 let p = RetryPolicy::default();
256 assert!(p.should_retry_error("connection reset by peer"));
257 assert!(p.should_retry_error("operation timed out"));
258 assert!(p.should_retry_error("DNS resolution failed"));
259 assert!(p.should_retry_error("broken pipe"));
260 assert!(p.should_retry_error("network is unreachable"));
261 }
262
263 #[test]
264 fn test_should_retry_non_network_false() {
265 let p = RetryPolicy::default();
266 assert!(!p.should_retry_error("file not found"));
267 assert!(!p.should_retry_error("permission denied"));
268 assert!(!p.should_retry_error("invalid URL"));
269 assert!(!p.should_retry_error("HTTP 404 Not Found"));
270 }
271
272 #[test]
273 fn test_is_exhausted_false_under_limit() {
274 let p = RetryPolicy::with_max_retries(RetryPolicy::default(), 3);
275 assert!(!p.is_exhausted(0));
276 assert!(!p.is_exhausted(1));
277 assert!(!p.is_exhausted(2));
278 assert!(!p.is_exhausted(3));
279 }
280
281 #[test]
282 fn test_is_exhausted_true_at_limit() {
283 let p = RetryPolicy::with_max_retries(RetryPolicy::default(), 3);
284 assert!(
285 p.is_exhausted(4),
286 "attempt 4 should be exhausted when max=3"
287 );
288 }
289
290 #[test]
291 fn test_stats_reasonable_values() {
292 let p = RetryPolicy::default();
293 let s = p.stats();
294 assert_eq!(s.max_retries, 3);
295 assert_eq!(s.retryable_codes_count, 6);
296 assert!(s.estimated_max_total_wait_sec > 0.0);
297 assert!(s.estimated_max_total_wait_sec < 30.0 + 10.0);
298 }
299
300 #[test]
301 fn test_custom_policy_override() {
302 let p = RetryPolicy::new(5, 2000).with_max_retries(10);
303 assert_eq!(p.max_retries, 10);
304 assert_eq!(p.base_wait_ms, 2000);
305
306 let w = p.compute_wait(1).unwrap();
307 assert_eq!(w, Duration::from_millis(2000));
308
309 assert!(!p.is_exhausted(9));
310 assert!(p.is_exhausted(11));
311 }
312
313 #[test]
314 fn test_attempt_record_lifecycle() {
315 let rec = AttemptRecord::new(2);
316 assert_eq!(rec.attempt_number, 2);
317 assert!(rec.error.is_none());
318 assert_eq!(rec.duration, Duration::ZERO);
319
320 let finished = rec.finish(Some("timeout".to_string()));
321 assert_eq!(finished.error.as_deref().unwrap(), "timeout");
322 let _ = finished.duration;
324 }
325}