armature_http_client/
retry.rs

1//! Retry configuration and strategies.
2
3use std::time::Duration;
4
5/// Retry configuration.
6#[derive(Debug, Clone)]
7pub struct RetryConfig {
8    /// Maximum number of retry attempts.
9    pub max_attempts: u32,
10    /// Backoff strategy.
11    pub backoff: BackoffStrategy,
12    /// Status codes that should trigger a retry.
13    pub retry_status_codes: Vec<u16>,
14    /// Whether to retry on connection errors.
15    pub retry_on_connection_error: bool,
16    /// Whether to retry on timeout errors.
17    pub retry_on_timeout: bool,
18    /// Maximum total time for all retries.
19    pub max_retry_time: Option<Duration>,
20}
21
22impl Default for RetryConfig {
23    fn default() -> Self {
24        Self {
25            max_attempts: 3,
26            backoff: BackoffStrategy::Exponential {
27                initial: Duration::from_millis(100),
28                max: Duration::from_secs(10),
29                multiplier: 2.0,
30            },
31            retry_status_codes: vec![408, 429, 500, 502, 503, 504],
32            retry_on_connection_error: true,
33            retry_on_timeout: true,
34            max_retry_time: Some(Duration::from_secs(60)),
35        }
36    }
37}
38
39impl RetryConfig {
40    /// Create a retry config with exponential backoff.
41    pub fn exponential(max_attempts: u32, initial_delay: Duration) -> Self {
42        Self {
43            max_attempts,
44            backoff: BackoffStrategy::Exponential {
45                initial: initial_delay,
46                max: Duration::from_secs(30),
47                multiplier: 2.0,
48            },
49            ..Default::default()
50        }
51    }
52
53    /// Create a retry config with linear backoff.
54    pub fn linear(max_attempts: u32, delay: Duration) -> Self {
55        Self {
56            max_attempts,
57            backoff: BackoffStrategy::Linear {
58                delay,
59                max: Duration::from_secs(30),
60            },
61            ..Default::default()
62        }
63    }
64
65    /// Create a retry config with constant delay.
66    pub fn constant(max_attempts: u32, delay: Duration) -> Self {
67        Self {
68            max_attempts,
69            backoff: BackoffStrategy::Constant(delay),
70            ..Default::default()
71        }
72    }
73
74    /// Create a retry config with no delay.
75    pub fn immediate(max_attempts: u32) -> Self {
76        Self {
77            max_attempts,
78            backoff: BackoffStrategy::None,
79            ..Default::default()
80        }
81    }
82
83    /// Set additional status codes to retry on.
84    pub fn with_status_codes(mut self, codes: Vec<u16>) -> Self {
85        self.retry_status_codes = codes;
86        self
87    }
88
89    /// Disable retry on connection errors.
90    pub fn no_retry_on_connection(mut self) -> Self {
91        self.retry_on_connection_error = false;
92        self
93    }
94
95    /// Disable retry on timeout errors.
96    pub fn no_retry_on_timeout(mut self) -> Self {
97        self.retry_on_timeout = false;
98        self
99    }
100
101    /// Set maximum total retry time.
102    pub fn with_max_retry_time(mut self, duration: Duration) -> Self {
103        self.max_retry_time = Some(duration);
104        self
105    }
106
107    /// Calculate delay for a given attempt.
108    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
109        self.backoff.delay_for_attempt(attempt)
110    }
111
112    /// Check if a status code should trigger a retry.
113    pub fn should_retry_status(&self, status: u16) -> bool {
114        self.retry_status_codes.contains(&status)
115    }
116}
117
118/// Backoff strategy for retries.
119#[derive(Debug, Clone)]
120pub enum BackoffStrategy {
121    /// No delay between retries.
122    None,
123    /// Constant delay between retries.
124    Constant(Duration),
125    /// Linear backoff: delay increases by a fixed amount.
126    Linear {
127        /// Delay increment per attempt.
128        delay: Duration,
129        /// Maximum delay.
130        max: Duration,
131    },
132    /// Exponential backoff: delay doubles each attempt.
133    Exponential {
134        /// Initial delay.
135        initial: Duration,
136        /// Maximum delay.
137        max: Duration,
138        /// Multiplier (typically 2.0).
139        multiplier: f64,
140    },
141}
142
143impl BackoffStrategy {
144    /// Calculate delay for a given attempt (0-indexed).
145    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
146        match self {
147            Self::None => Duration::ZERO,
148            Self::Constant(d) => *d,
149            Self::Linear { delay, max } => {
150                let total = delay.saturating_mul(attempt + 1);
151                total.min(*max)
152            }
153            Self::Exponential {
154                initial,
155                max,
156                multiplier,
157            } => {
158                let factor = multiplier.powi(attempt as i32);
159                let millis = (initial.as_millis() as f64 * factor) as u64;
160                Duration::from_millis(millis).min(*max)
161            }
162        }
163    }
164}
165
166/// Retry strategy trait for custom retry logic.
167pub trait RetryStrategy: Send + Sync {
168    /// Check if the request should be retried.
169    fn should_retry(&self, attempt: u32, error: &crate::HttpClientError) -> bool;
170
171    /// Get the delay before the next retry.
172    fn retry_delay(&self, attempt: u32) -> Duration;
173}
174
175impl RetryStrategy for RetryConfig {
176    fn should_retry(&self, attempt: u32, error: &crate::HttpClientError) -> bool {
177        if attempt >= self.max_attempts {
178            return false;
179        }
180
181        match error {
182            crate::HttpClientError::Timeout(_) => self.retry_on_timeout,
183            crate::HttpClientError::Connection(_) => self.retry_on_connection_error,
184            crate::HttpClientError::Response { status, .. } => {
185                self.retry_status_codes.contains(status)
186            }
187            crate::HttpClientError::Http(e) => {
188                if e.is_timeout() {
189                    self.retry_on_timeout
190                } else if e.is_connect() {
191                    self.retry_on_connection_error
192                } else if let Some(status) = e.status() {
193                    self.retry_status_codes.contains(&status.as_u16())
194                } else {
195                    false
196                }
197            }
198            _ => false,
199        }
200    }
201
202    fn retry_delay(&self, attempt: u32) -> Duration {
203        self.delay_for_attempt(attempt)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_exponential_backoff() {
213        let strategy = BackoffStrategy::Exponential {
214            initial: Duration::from_millis(100),
215            max: Duration::from_secs(10),
216            multiplier: 2.0,
217        };
218
219        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
220        assert_eq!(strategy.delay_for_attempt(1), Duration::from_millis(200));
221        assert_eq!(strategy.delay_for_attempt(2), Duration::from_millis(400));
222        assert_eq!(strategy.delay_for_attempt(3), Duration::from_millis(800));
223    }
224
225    #[test]
226    fn test_linear_backoff() {
227        let strategy = BackoffStrategy::Linear {
228            delay: Duration::from_millis(100),
229            max: Duration::from_secs(1),
230        };
231
232        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(100));
233        assert_eq!(strategy.delay_for_attempt(1), Duration::from_millis(200));
234        assert_eq!(strategy.delay_for_attempt(9), Duration::from_secs(1));
235    }
236
237    #[test]
238    fn test_constant_backoff() {
239        let strategy = BackoffStrategy::Constant(Duration::from_millis(500));
240
241        assert_eq!(strategy.delay_for_attempt(0), Duration::from_millis(500));
242        assert_eq!(strategy.delay_for_attempt(5), Duration::from_millis(500));
243    }
244}