Skip to main content

vtcode_commons/
retry.rs

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