Skip to main content

vtcode_commons/
retry.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    clippy::cast_possible_wrap,
4    reason = "Retry exponents and jitter are clamped to the supported retry range before conversion."
5)]
6
7//! Canonical retry policy shared across the workspace.
8//!
9//! This module owns the retry *policy math*: attempt budgets, exponential
10//! backoff with an optional deterministic jitter, and category-based retry
11//! decisions built on [`ErrorCategory::is_retryable`]. Domain-specific
12//! adapters (typed error downcasts, tool-aware timeout rules, LLM
13//! `Retry-After` extraction) live in `vtcode-core::retry` as an extension
14//! trait over this policy.
15//!
16//! Wire-level HTTP clients that only need "should I retry this call?" use
17//! [`RetryPolicy::classify_anyhow`] / [`RetryPolicy::classify_status`];
18//! richer loops use [`RetryPolicy::decision_for_category`].
19
20use std::time::Duration;
21
22use crate::error_category::{ErrorCategory, classify_anyhow_error};
23
24/// Typed retry policy shared across runtime layers.
25#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
26#[must_use]
27pub struct RetryPolicy {
28    /// Maximum number of total attempts, including the initial call.
29    pub max_attempts: u32,
30    pub initial_delay: Duration,
31    pub max_delay: Duration,
32    pub multiplier: f64,
33    pub jitter: f64,
34}
35
36impl RetryPolicy {
37    pub const DEFAULT: Self = Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0);
38
39    pub const fn new(max_attempts: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
40        Self {
41            max_attempts: if max_attempts < 1 { 1 } else { max_attempts },
42            initial_delay,
43            max_delay,
44            multiplier: if multiplier < 1.0 { 1.0 } else { multiplier },
45            jitter: 0.0,
46        }
47    }
48
49    pub const fn from_retries(max_retries: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
50        Self::new(max_retries.saturating_add(1), initial_delay, max_delay, multiplier)
51    }
52
53    /// Millisecond-based constructor for wire clients.
54    ///
55    /// Uses a 2.0 multiplier and no jitter, so
56    /// [`Self::delay_for_attempt`] reproduces the classic
57    /// `base_ms << attempt` doubling curve capped at `max_delay_ms`.
58    fn simple(max_retries: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
59        Self::from_retries(max_retries, Duration::from_millis(base_delay_ms), Duration::from_millis(max_delay_ms), 2.0)
60    }
61
62    #[must_use]
63    pub fn delay_for_attempt(&self, attempt_index: u32) -> Duration {
64        let multiplier = self.multiplier.powi(attempt_index as i32);
65        let base_delay = Duration::try_from_secs_f64(self.initial_delay.as_secs_f64() * multiplier)
66            .unwrap_or(self.max_delay)
67            .min(self.max_delay);
68
69        if !self.jitter.is_finite() || self.jitter <= 0.0 {
70            return base_delay;
71        }
72
73        #[allow(
74            clippy::cast_sign_loss,
75            reason = "Intentional compatibility, platform, or test-only suppression."
76        )]
77        let max_jitter_ms = (base_delay.as_millis() as f64 * self.jitter)
78            .round()
79            .clamp(0.0, u64::MAX as f64) as u64;
80        if max_jitter_ms == 0 {
81            return base_delay;
82        }
83
84        let offset = (u64::from(attempt_index) * 31) % max_jitter_ms.saturating_add(1);
85        base_delay.saturating_add(Duration::from_millis(offset))
86    }
87
88    pub fn decision_for_category(
89        &self,
90        category: ErrorCategory,
91        attempt_index: u32,
92        retry_after: Option<Duration>,
93    ) -> RetryDecision {
94        let has_remaining_attempts = attempt_index.saturating_add(1) < self.max_attempts;
95        if !category.is_retryable() || !has_remaining_attempts {
96            return RetryDecision {
97                category,
98                retryable: false,
99                delay: None,
100                retry_after,
101            };
102        }
103
104        let delay = retry_after.unwrap_or_else(|| self.delay_for_attempt(attempt_index));
105        RetryDecision {
106            category,
107            retryable: true,
108            delay: Some(delay),
109            retry_after,
110        }
111    }
112
113    /// Classify an `anyhow::Error` for retry eligibility.
114    ///
115    /// Attempt-agnostic: `retryable` reflects only the error category, not
116    /// the remaining attempt budget. Wire clients that manage their own
117    /// attempt counting use this; loops that want budget-aware decisions
118    /// use [`Self::decision_for_category`].
119    pub fn classify_anyhow(&self, error: &anyhow::Error) -> RetryDecision {
120        let category = classify_anyhow_error(error);
121        RetryDecision {
122            category,
123            retryable: category.is_retryable(),
124            delay: None,
125            retry_after: None,
126        }
127    }
128
129    /// Classify an HTTP status code for retry eligibility.
130    ///
131    /// Attempt-agnostic, like [`Self::classify_anyhow`].
132    pub fn classify_status(&self, status: u16) -> RetryDecision {
133        let category = match status {
134            429 => ErrorCategory::RateLimit,
135            500 | 502 | 504 => ErrorCategory::Network,
136            503 => ErrorCategory::ServiceUnavailable,
137            401 | 403 => ErrorCategory::Authentication,
138            _ => ErrorCategory::ExecutionError,
139        };
140        RetryDecision {
141            category,
142            retryable: category.is_retryable(),
143            delay: None,
144            retry_after: None,
145        }
146    }
147}
148
149impl Default for RetryPolicy {
150    fn default() -> Self {
151        Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0)
152    }
153}
154
155/// Result of classifying a failure for retry handling.
156#[derive(Debug, Clone, PartialEq, Eq)]
157#[must_use = "retry decisions must be handled before another attempt"]
158pub struct RetryDecision {
159    pub category: ErrorCategory,
160    pub retryable: bool,
161    pub delay: Option<Duration>,
162    pub retry_after: Option<Duration>,
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn default_policy_allows_two_retries() {
171        let policy = RetryPolicy::default();
172        assert_eq!(policy.max_attempts, 3);
173        assert_eq!(policy.initial_delay, Duration::from_secs(1));
174        assert_eq!(policy.max_delay, Duration::from_secs(60));
175    }
176
177    #[test]
178    fn classify_status_rate_limit() {
179        let policy = RetryPolicy::default();
180        let decision = policy.classify_status(429);
181        assert!(decision.retryable);
182        assert_eq!(decision.category, ErrorCategory::RateLimit);
183    }
184
185    #[test]
186    fn classify_status_server_error() {
187        let policy = RetryPolicy::default();
188        let decision = policy.classify_status(503);
189        assert!(decision.retryable);
190        assert_eq!(decision.category, ErrorCategory::ServiceUnavailable);
191    }
192
193    #[test]
194    fn classify_status_auth_not_retryable() {
195        let policy = RetryPolicy::default();
196        let decision = policy.classify_status(401);
197        assert!(!decision.retryable);
198        assert_eq!(decision.category, ErrorCategory::Authentication);
199    }
200
201    #[test]
202    fn classify_anyhow_network_error() {
203        let policy = RetryPolicy::default();
204        let err = anyhow::anyhow!("connection refused");
205        let decision = policy.classify_anyhow(&err);
206        assert!(decision.retryable);
207    }
208
209    #[test]
210    fn simple_policy_matches_bit_shift_doubling() {
211        // Parity with the historical `base_ms << attempt` curve used by
212        // wire clients before consolidation.
213        let policy = RetryPolicy::simple(10, 1000, 5000);
214        let legacy = |attempt: u32| -> u64 { 1000u64.saturating_mul(1u64 << attempt.min(16)).min(5000) };
215        for attempt in 0..6 {
216            assert_eq!(
217                policy.delay_for_attempt(attempt),
218                Duration::from_millis(legacy(attempt)),
219                "delay mismatch at attempt {attempt}"
220            );
221        }
222    }
223
224    #[test]
225    fn delay_for_attempt_clamps_overflowing_backoff_to_max_delay() {
226        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), f64::MAX);
227
228        assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(8));
229    }
230
231    #[test]
232    fn delay_for_attempt_ignores_non_finite_jitter() {
233        let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
234        policy.jitter = f64::INFINITY;
235
236        assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
237    }
238
239    #[test]
240    fn delay_for_attempt_handles_huge_finite_jitter() {
241        let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
242        policy.jitter = f64::MAX;
243
244        assert!(policy.delay_for_attempt(1) >= Duration::from_secs(2));
245    }
246
247    #[test]
248    fn decision_for_category_respects_attempt_budget() {
249        let policy = RetryPolicy::from_retries(1, Duration::from_secs(1), Duration::from_secs(8), 2.0);
250
251        let first = policy.decision_for_category(ErrorCategory::Network, 0, None);
252        assert!(first.retryable);
253        assert_eq!(first.delay, Some(Duration::from_secs(1)));
254
255        let exhausted = policy.decision_for_category(ErrorCategory::Network, 1, None);
256        assert!(!exhausted.retryable);
257        assert!(exhausted.delay.is_none());
258    }
259
260    #[test]
261    fn decision_for_category_prefers_retry_after() {
262        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
263
264        let decision = policy.decision_for_category(ErrorCategory::RateLimit, 0, Some(Duration::from_secs(7)));
265        assert!(decision.retryable);
266        assert_eq!(decision.delay, Some(Duration::from_secs(7)));
267        assert_eq!(decision.retry_after, Some(Duration::from_secs(7)));
268    }
269}