Skip to main content

aria2_core/engine/
retry_policy.rs

1use 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    /// Alias for `max_retries` for compatibility with the engine retry loop.
56    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    /// Compute wait duration using exponential backoff (Duration-based API).
70    ///
71    /// This is equivalent to [`compute_wait`](Self::compute_wait) but returns
72    /// a `Duration` directly, clamped between `base_wait` and `max_wait`.
73    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    /// Check whether a retry should be attempted based on the error type.
88    ///
89    /// Returns `true` if the attempt count has not been exhausted and the
90    /// error is a recoverable [`Aria2Error`].
91    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        // Just verify duration field exists (u128 is always >= 0)
323        let _ = finished.duration;
324    }
325}