aperture_cli/resilience/
mod.rs1use crate::error::Error;
2use std::time::{Duration, Instant};
3use tokio::time::sleep;
4
5#[derive(Debug, Clone)]
7pub struct RetryConfig {
8 pub max_attempts: usize,
9 pub initial_delay_ms: u64,
10 pub max_delay_ms: u64,
11 pub backoff_multiplier: f64,
12 pub jitter: bool,
13}
14
15impl Default for RetryConfig {
16 fn default() -> Self {
17 Self {
18 max_attempts: 3,
19 initial_delay_ms: 100,
20 max_delay_ms: 5000,
21 backoff_multiplier: 2.0,
22 jitter: true,
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
29pub struct TimeoutConfig {
30 pub connect_timeout_ms: u64,
31 pub request_timeout_ms: u64,
32}
33
34impl Default for TimeoutConfig {
35 fn default() -> Self {
36 Self {
37 connect_timeout_ms: 10_000, request_timeout_ms: 30_000, }
40 }
41}
42
43#[must_use]
45pub fn is_retryable_error(error: &reqwest::Error) -> bool {
46 if error.is_connect() {
48 return true;
49 }
50
51 if error.is_timeout() {
53 return true;
54 }
55
56 error.status().is_none_or(|status| match status.as_u16() {
58 408 | 429 => true, 500..=599 => !matches!(status.as_u16(), 501 | 505), _ => false, })
66}
67
68#[must_use]
70#[allow(
71 clippy::cast_precision_loss,
72 clippy::cast_possible_truncation,
73 clippy::cast_sign_loss,
74 clippy::cast_possible_wrap
75)]
76pub fn calculate_retry_delay(config: &RetryConfig, attempt: usize) -> Duration {
77 let base_delay = config.initial_delay_ms as f64;
78 let attempt_i32 = attempt.min(30) as i32; let delay_ms =
80 (base_delay * config.backoff_multiplier.powi(attempt_i32)).min(config.max_delay_ms as f64);
81
82 let final_delay_ms = if config.jitter {
83 let jitter_factor = fastrand::f64().mul_add(0.25, 1.0);
85 delay_ms * jitter_factor
86 } else {
87 delay_ms
88 } as u64;
89
90 Duration::from_millis(final_delay_ms)
91}
92
93pub async fn execute_with_retry<F, Fut, T>(
98 config: &RetryConfig,
99 _operation_name: &str,
100 mut operation: F,
101) -> Result<T, Error>
102where
103 F: FnMut() -> Fut,
104 Fut: std::future::Future<Output = Result<T, reqwest::Error>>,
105{
106 let start_time = Instant::now();
107 let mut last_error = None;
108
109 for attempt in 0..config.max_attempts {
110 match operation().await {
111 Ok(result) => {
112 return Ok(result);
114 }
115 Err(error) => {
116 let is_last_attempt = attempt + 1 >= config.max_attempts;
117 let is_retryable = is_retryable_error(&error);
118
119 if is_last_attempt || !is_retryable {
120 let error_message = error.to_string();
121 last_error = Some(error_message.clone());
122
123 if !is_retryable {
124 return Err(Error::TransientNetworkError {
125 reason: error_message,
126 retryable: false,
127 });
128 }
129 break;
130 }
131
132 let delay = calculate_retry_delay(config, attempt);
134
135 sleep(delay).await;
136 last_error = Some(error.to_string());
137 }
138 }
139 }
140
141 let duration = start_time.elapsed();
142 Err(Error::RetryLimitExceeded {
143 attempts: config.max_attempts,
144 #[allow(clippy::cast_possible_truncation)]
145 duration_ms: duration.as_millis().min(u128::from(u64::MAX)) as u64,
146 last_error: last_error.unwrap_or_else(|| "Unknown error".to_string()),
147 })
148}
149
150pub fn create_resilient_client(timeout_config: &TimeoutConfig) -> Result<reqwest::Client, Error> {
155 reqwest::Client::builder()
156 .connect_timeout(Duration::from_millis(timeout_config.connect_timeout_ms))
157 .timeout(Duration::from_millis(timeout_config.request_timeout_ms))
158 .build()
159 .map_err(|e| Error::RequestFailed {
160 reason: format!("Failed to create resilient HTTP client: {e}"),
161 })
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_calculate_retry_delay() {
170 let config = RetryConfig {
171 max_attempts: 5,
172 initial_delay_ms: 100,
173 max_delay_ms: 1000,
174 backoff_multiplier: 2.0,
175 jitter: false,
176 };
177
178 let delay1 = calculate_retry_delay(&config, 0);
179 let delay2 = calculate_retry_delay(&config, 1);
180 let delay3 = calculate_retry_delay(&config, 2);
181
182 assert_eq!(delay1.as_millis(), 100);
183 assert_eq!(delay2.as_millis(), 200);
184 assert_eq!(delay3.as_millis(), 400);
185
186 let delay_max = calculate_retry_delay(&config, 10);
188 assert_eq!(delay_max.as_millis(), 1000);
189 }
190
191 #[test]
192 fn test_calculate_retry_delay_with_jitter() {
193 let config = RetryConfig {
194 max_attempts: 3,
195 initial_delay_ms: 100,
196 max_delay_ms: 1000,
197 backoff_multiplier: 2.0,
198 jitter: true,
199 };
200
201 let delay1 = calculate_retry_delay(&config, 0);
202 let delay2 = calculate_retry_delay(&config, 0);
203
204 assert!(delay1.as_millis() >= 100 && delay1.as_millis() <= 125);
207 assert!(delay2.as_millis() >= 100 && delay2.as_millis() <= 125);
208 }
209
210 #[test]
211 fn test_default_configs() {
212 let retry_config = RetryConfig::default();
213 assert_eq!(retry_config.max_attempts, 3);
214 assert_eq!(retry_config.initial_delay_ms, 100);
215
216 let timeout_config = TimeoutConfig::default();
217 assert_eq!(timeout_config.connect_timeout_ms, 10_000);
218 assert_eq!(timeout_config.request_timeout_ms, 30_000);
219 }
220}