Skip to main content

oxihttp_client/
retry.rs

1//! Retry logic with exponential backoff for the HTTP client.
2
3use std::collections::HashSet;
4use std::time::Duration;
5
6/// Configuration for automatic request retries.
7#[derive(Debug, Clone)]
8pub struct RetryPolicy {
9    /// Maximum number of retry attempts.
10    pub max_retries: u32,
11    /// Base delay for exponential backoff (e.g. 100ms).
12    pub backoff_base: Duration,
13    /// Maximum delay between retries.
14    pub backoff_max: Duration,
15    /// HTTP status codes that trigger a retry.
16    pub retryable_status_codes: HashSet<u16>,
17    /// Whether to retry on connection errors.
18    pub retry_on_connection_error: bool,
19    /// Whether to retry on timeout errors.
20    pub retry_on_timeout: bool,
21}
22
23impl RetryPolicy {
24    /// Create a new retry policy with the given maximum retry count.
25    pub fn new(max_retries: u32) -> Self {
26        let mut retryable = HashSet::new();
27        // Common retryable status codes
28        retryable.insert(429); // Too Many Requests
29        retryable.insert(500); // Internal Server Error
30        retryable.insert(502); // Bad Gateway
31        retryable.insert(503); // Service Unavailable
32        retryable.insert(504); // Gateway Timeout
33
34        Self {
35            max_retries,
36            backoff_base: Duration::from_millis(100),
37            backoff_max: Duration::from_secs(30),
38            retryable_status_codes: retryable,
39            retry_on_connection_error: true,
40            retry_on_timeout: true,
41        }
42    }
43
44    /// Set the base delay for exponential backoff.
45    pub fn with_backoff_base(mut self, base: Duration) -> Self {
46        self.backoff_base = base;
47        self
48    }
49
50    /// Set the maximum delay between retries.
51    pub fn with_backoff_max(mut self, max: Duration) -> Self {
52        self.backoff_max = max;
53        self
54    }
55
56    /// Set the retryable status codes.
57    pub fn with_retryable_status_codes(mut self, codes: HashSet<u16>) -> Self {
58        self.retryable_status_codes = codes;
59        self
60    }
61
62    /// Add a retryable status code.
63    pub fn add_retryable_status(mut self, code: u16) -> Self {
64        self.retryable_status_codes.insert(code);
65        self
66    }
67
68    /// Set whether to retry on connection errors.
69    pub fn with_retry_on_connection_error(mut self, retry: bool) -> Self {
70        self.retry_on_connection_error = retry;
71        self
72    }
73
74    /// Set whether to retry on timeout errors.
75    pub fn with_retry_on_timeout(mut self, retry: bool) -> Self {
76        self.retry_on_timeout = retry;
77        self
78    }
79
80    /// Returns `true` if the given status code should trigger a retry.
81    pub fn should_retry_status(&self, status_code: u16) -> bool {
82        self.retryable_status_codes.contains(&status_code)
83    }
84
85    /// Calculate the backoff delay for the given attempt number (0-indexed).
86    pub fn backoff_delay(&self, attempt: u32) -> Duration {
87        let delay = self
88            .backoff_base
89            .saturating_mul(2u32.saturating_pow(attempt));
90        std::cmp::min(delay, self.backoff_max)
91    }
92}
93
94impl Default for RetryPolicy {
95    fn default() -> Self {
96        Self::new(3)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_default_retry_policy() {
106        let policy = RetryPolicy::default();
107        assert_eq!(policy.max_retries, 3);
108        assert!(policy.should_retry_status(503));
109        assert!(policy.should_retry_status(429));
110        assert!(!policy.should_retry_status(404));
111    }
112
113    #[test]
114    fn test_backoff_delay() {
115        let policy = RetryPolicy::new(5).with_backoff_base(Duration::from_millis(100));
116        assert_eq!(policy.backoff_delay(0), Duration::from_millis(100));
117        assert_eq!(policy.backoff_delay(1), Duration::from_millis(200));
118        assert_eq!(policy.backoff_delay(2), Duration::from_millis(400));
119        assert_eq!(policy.backoff_delay(3), Duration::from_millis(800));
120    }
121
122    #[test]
123    fn test_backoff_delay_capped() {
124        let policy = RetryPolicy::new(5)
125            .with_backoff_base(Duration::from_secs(1))
126            .with_backoff_max(Duration::from_secs(5));
127        assert_eq!(policy.backoff_delay(0), Duration::from_secs(1));
128        assert_eq!(policy.backoff_delay(1), Duration::from_secs(2));
129        assert_eq!(policy.backoff_delay(2), Duration::from_secs(4));
130        assert_eq!(policy.backoff_delay(3), Duration::from_secs(5)); // capped
131    }
132
133    #[test]
134    fn test_custom_retryable_status() {
135        let policy = RetryPolicy::new(1).add_retryable_status(418);
136        assert!(policy.should_retry_status(418));
137        assert!(policy.should_retry_status(503)); // still has defaults
138    }
139}