Skip to main content

aptu_core/
retry.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Retry logic with exponential backoff for transient failures.
4//!
5//! Provides helpers to detect retryable errors and configure exponential backoff
6//! with jitter for HTTP requests and other transient operations.
7
8use backon::ExponentialBuilder;
9
10/// Determines if an HTTP status code is retryable.
11///
12/// Retryable status codes are:
13/// - 429 (Too Many Requests / Rate Limited)
14/// - 500 (Internal Server Error)
15/// - 502 (Bad Gateway)
16/// - 503 (Service Unavailable)
17/// - 504 (Gateway Timeout)
18///
19/// # Arguments
20///
21/// * `status` - HTTP status code as u16
22///
23/// # Returns
24///
25/// `true` if the status code indicates a transient error that should be retried
26#[must_use]
27pub fn is_retryable_http(status: u16) -> bool {
28    matches!(status, 429 | 500 | 502 | 503 | 504)
29}
30
31/// Determines if an octocrab error is retryable.
32///
33/// Retryable octocrab errors include:
34/// - GitHub API errors with retryable status codes (429, 500, 502, 503, 504, 403)
35/// - Service errors (transient)
36/// - Hyper errors (network-related)
37///
38/// # Arguments
39///
40/// * `e` - Reference to an octocrab error
41///
42/// # Returns
43///
44/// `true` if the error is transient and should be retried
45#[must_use]
46pub(crate) fn is_retryable_octocrab(e: &octocrab::Error) -> bool {
47    match e {
48        octocrab::Error::GitHub { source, .. } => {
49            // Check if the GitHub error has a retryable status code
50            // 403 is included for GitHub secondary rate limits
51            matches!(
52                source.status_code.as_u16(),
53                429 | 500 | 502 | 503 | 504 | 403
54            )
55        }
56        octocrab::Error::Service { .. } | octocrab::Error::Hyper { .. } => true,
57        _ => false,
58    }
59}
60
61/// Determines if an anyhow error is retryable.
62///
63/// Checks if the error chain contains a retryable HTTP status code or network error.
64/// Supports reqwest, octocrab, and `AptuError` variants.
65///
66/// # Arguments
67///
68/// * `e` - Reference to an anyhow error
69///
70/// # Returns
71///
72/// `true` if the error is transient and should be retried
73#[must_use]
74pub fn is_retryable_anyhow(e: &anyhow::Error) -> bool {
75    // Check if it's an octocrab error
76    if let Some(oct_err) = e.downcast_ref::<octocrab::Error>() {
77        return is_retryable_octocrab(oct_err);
78    }
79
80    // Check if it's a reqwest error
81    if let Some(req_err) = e.downcast_ref::<reqwest::Error>() {
82        // Retryable network errors
83        if req_err.is_timeout() || req_err.is_connect() {
84            return true;
85        }
86        // Check status code if available
87        if let Some(status) = req_err.status() {
88            return is_retryable_http(status.as_u16());
89        }
90    }
91
92    // Check if it's our AptuError with RateLimited or TruncatedResponse
93    if let Some(aptu_err) = e.downcast_ref::<crate::error::AptuError>() {
94        return matches!(
95            aptu_err,
96            crate::error::AptuError::RateLimited { .. }
97                | crate::error::AptuError::TruncatedResponse { .. }
98        );
99    }
100
101    false
102}
103
104/// Creates a configured exponential backoff builder for retries.
105///
106/// Configuration per SPEC.md:
107/// - Factor: 2 (exponential growth)
108/// - Min delay: 1 second
109/// - Max times: 3 (total of 3 attempts)
110/// - Jitter: enabled
111///
112/// # Returns
113///
114/// An `ExponentialBuilder` configured for retry operations
115#[must_use]
116pub fn retry_backoff() -> ExponentialBuilder {
117    ExponentialBuilder::default()
118        .with_factor(2.0)
119        .with_min_delay(std::time::Duration::from_secs(1))
120        .with_max_times(3)
121        .with_jitter()
122}
123
124/// Maximum retry-after delay to prevent excessive waits (120 seconds).
125const MAX_RETRY_AFTER_SECS: u64 = 120;
126
127/// Extracts `retry_after` value from a `RateLimited` error if present.
128///
129/// Checks the top-level error for an `AptuError::RateLimited` variant and returns
130/// its `retry_after` value. Caps the value at `MAX_RETRY_AFTER_SECS` to prevent
131/// excessive waits.
132///
133/// # Arguments
134///
135/// * `e` - Reference to an anyhow error
136///
137/// # Returns
138///
139/// `Some(duration)` if a `RateLimited` error is found with `retry_after` > 0,
140/// `None` otherwise
141#[must_use]
142pub fn extract_retry_after(e: &anyhow::Error) -> Option<std::time::Duration> {
143    if let Some(crate::error::AptuError::RateLimited { retry_after, .. }) =
144        e.downcast_ref::<crate::error::AptuError>()
145        && *retry_after > 0
146    {
147        let capped = (*retry_after).min(MAX_RETRY_AFTER_SECS);
148        return Some(std::time::Duration::from_secs(capped));
149    }
150    None
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_is_retryable_http_429() {
159        assert!(is_retryable_http(429));
160    }
161
162    #[test]
163    fn test_is_retryable_http_500() {
164        assert!(is_retryable_http(500));
165    }
166
167    #[test]
168    fn test_is_retryable_http_502() {
169        assert!(is_retryable_http(502));
170    }
171
172    #[test]
173    fn test_is_retryable_http_503() {
174        assert!(is_retryable_http(503));
175    }
176
177    #[test]
178    fn test_is_retryable_http_504() {
179        assert!(is_retryable_http(504));
180    }
181
182    #[test]
183    fn test_is_retryable_http_non_retryable() {
184        assert!(!is_retryable_http(400));
185        assert!(!is_retryable_http(401));
186        assert!(!is_retryable_http(403));
187        assert!(!is_retryable_http(404));
188        assert!(!is_retryable_http(200));
189        assert!(!is_retryable_http(201));
190    }
191
192    #[test]
193    fn test_retry_backoff_configuration() {
194        let backoff = retry_backoff();
195        // Verify it's an ExponentialBuilder (type check at compile time)
196        let _: ExponentialBuilder = backoff;
197    }
198
199    #[test]
200    fn test_is_retryable_anyhow_with_non_retryable() {
201        let err = anyhow::anyhow!("some other error");
202        assert!(!is_retryable_anyhow(&err));
203    }
204
205    #[test]
206    fn test_is_retryable_http_retryable_codes() {
207        assert!(is_retryable_http(429));
208        assert!(is_retryable_http(500));
209        assert!(is_retryable_http(502));
210        assert!(is_retryable_http(503));
211        assert!(is_retryable_http(504));
212    }
213
214    #[test]
215    fn test_is_retryable_http_non_retryable_codes() {
216        assert!(!is_retryable_http(400));
217        assert!(!is_retryable_http(401));
218        assert!(!is_retryable_http(403));
219        assert!(!is_retryable_http(404));
220        assert!(!is_retryable_http(200));
221        assert!(!is_retryable_http(201));
222    }
223
224    #[test]
225    fn test_is_retryable_anyhow_with_truncated_response() {
226        let err = anyhow::anyhow!(crate::error::AptuError::TruncatedResponse {
227            provider: "OpenRouter".to_string(),
228        });
229        assert!(is_retryable_anyhow(&err));
230    }
231
232    #[test]
233    fn test_is_retryable_anyhow_with_rate_limited() {
234        let err = anyhow::anyhow!(crate::error::AptuError::RateLimited {
235            provider: "OpenRouter".to_string(),
236            retry_after: 60,
237        });
238        assert!(is_retryable_anyhow(&err));
239    }
240
241    #[test]
242    fn test_extract_retry_after_with_valid_value() {
243        let err = anyhow::anyhow!(crate::error::AptuError::RateLimited {
244            provider: "OpenRouter".to_string(),
245            retry_after: 60,
246        });
247        let duration = extract_retry_after(&err);
248        assert_eq!(duration, Some(std::time::Duration::from_secs(60)));
249    }
250
251    #[test]
252    fn test_extract_retry_after_with_zero_value() {
253        let err = anyhow::anyhow!(crate::error::AptuError::RateLimited {
254            provider: "OpenRouter".to_string(),
255            retry_after: 0,
256        });
257        let duration = extract_retry_after(&err);
258        assert_eq!(duration, None);
259    }
260
261    #[test]
262    fn test_extract_retry_after_with_capped_value() {
263        let err = anyhow::anyhow!(crate::error::AptuError::RateLimited {
264            provider: "OpenRouter".to_string(),
265            retry_after: 300,
266        });
267        let duration = extract_retry_after(&err);
268        assert_eq!(duration, Some(std::time::Duration::from_secs(120)));
269    }
270
271    #[test]
272    fn test_extract_retry_after_with_non_rate_limited_error() {
273        let err = anyhow::anyhow!("some other error");
274        let duration = extract_retry_after(&err);
275        assert_eq!(duration, None);
276    }
277
278    #[test]
279    fn test_extract_retry_after_with_truncated_response() {
280        let err = anyhow::anyhow!(crate::error::AptuError::TruncatedResponse {
281            provider: "OpenRouter".to_string(),
282        });
283        let duration = extract_retry_after(&err);
284        assert_eq!(duration, None);
285    }
286}