Skip to main content

vtcode_commons/
retry.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    reason = "Retry exponents and jitter are clamped to the supported retry range before conversion."
4)]
5
6//! Canonical retry policy shared across the workspace.
7//!
8//! This module owns the retry *policy math*: attempt budgets, exponential
9//! backoff with an optional deterministic jitter, and category-based retry
10//! decisions built on [`ErrorCategory::is_retryable`]. Domain-specific
11//! adapters (typed error downcasts, tool-aware timeout rules, LLM
12//! `Retry-After` extraction) live in `vtcode-core::retry` as an extension
13//! trait over this policy.
14//!
15//! Wire-level HTTP clients that only need "should I retry this call?" use
16//! [`RetryPolicy::classify_anyhow`] / [`RetryPolicy::classify_status`];
17//! richer loops use [`RetryPolicy::decision_for_category`].
18
19use std::time::Duration;
20
21use crate::error_category::{ErrorCategory, classify_anyhow_error};
22
23/// Typed retry policy shared across runtime layers.
24#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
25#[must_use]
26pub struct RetryPolicy {
27    /// Maximum number of total attempts, including the initial call.
28    pub max_attempts: u32,
29    pub initial_delay: Duration,
30    pub max_delay: Duration,
31    pub multiplier: f64,
32    pub jitter: f64,
33}
34
35impl RetryPolicy {
36    pub const DEFAULT: Self = Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0);
37
38    /// Standard jitter ratio for outbound retries: up to 15% deterministic
39    /// offset. `DEFAULT` keeps `0.0` so pinned delay math stays exact; call
40    /// sites that retry shared external services opt in via [`Self::with_jitter`].
41    pub const DEFAULT_JITTER: f64 = 0.15;
42
43    /// Builder-style jitter override, so call sites need no `mut` binding.
44    pub fn with_jitter(mut self, jitter: f64) -> Self {
45        self.jitter = jitter;
46        self
47    }
48
49    pub const fn new(max_attempts: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
50        Self {
51            max_attempts: if max_attempts < 1 { 1 } else { max_attempts },
52            initial_delay,
53            max_delay,
54            multiplier: if multiplier < 1.0 { 1.0 } else { multiplier },
55            jitter: 0.0,
56        }
57    }
58
59    pub const fn from_retries(max_retries: u32, initial_delay: Duration, max_delay: Duration, multiplier: f64) -> Self {
60        Self::new(max_retries.saturating_add(1), initial_delay, max_delay, multiplier)
61    }
62
63    /// Millisecond-based constructor for wire clients.
64    ///
65    /// Uses a 2.0 multiplier and no jitter, so
66    /// [`Self::delay_for_attempt`] reproduces the classic
67    /// `base_ms << attempt` doubling curve capped at `max_delay_ms`.
68    fn simple(max_retries: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
69        Self::from_retries(max_retries, Duration::from_millis(base_delay_ms), Duration::from_millis(max_delay_ms), 2.0)
70    }
71
72    #[must_use]
73    pub fn delay_for_attempt(&self, attempt_index: u32) -> Duration {
74        // Saturate, never wrap: attempts past the `i32` exponent range must pin
75        // at the cap instead of collapsing to a tiny delay.
76        let exponent = i32::try_from(attempt_index).unwrap_or(i32::MAX);
77        let multiplier = self.multiplier.powi(exponent);
78        let base_delay = Duration::try_from_secs_f64(self.initial_delay.as_secs_f64() * multiplier)
79            .unwrap_or(self.max_delay)
80            .min(self.max_delay);
81
82        if !self.jitter.is_finite() || self.jitter <= 0.0 {
83            return base_delay;
84        }
85
86        #[allow(
87            clippy::cast_sign_loss,
88            reason = "Intentional compatibility, platform, or test-only suppression."
89        )]
90        let max_jitter_ms = (base_delay.as_millis() as f64 * self.jitter)
91            .round()
92            .clamp(0.0, u64::MAX as f64) as u64;
93        if max_jitter_ms == 0 {
94            return base_delay;
95        }
96
97        let offset = (u64::from(attempt_index) * 31) % max_jitter_ms.saturating_add(1);
98        base_delay.saturating_add(Duration::from_millis(offset))
99    }
100
101    pub fn decision_for_category(
102        &self,
103        category: ErrorCategory,
104        attempt_index: u32,
105        retry_after: Option<Duration>,
106    ) -> RetryDecision {
107        let has_remaining_attempts = attempt_index.saturating_add(1) < self.max_attempts;
108        if !category.is_retryable() || !has_remaining_attempts {
109            return RetryDecision {
110                category,
111                retryable: false,
112                delay: None,
113                retry_after,
114            };
115        }
116
117        let delay = retry_after.unwrap_or_else(|| self.delay_for_attempt(attempt_index));
118        RetryDecision {
119            category,
120            retryable: true,
121            delay: Some(delay),
122            retry_after,
123        }
124    }
125
126    /// Classify an `anyhow::Error` for retry eligibility.
127    ///
128    /// Attempt-agnostic: `retryable` reflects only the error category, not
129    /// the remaining attempt budget. Wire clients that manage their own
130    /// attempt counting use this; loops that want budget-aware decisions
131    /// use [`Self::decision_for_category`].
132    pub fn classify_anyhow(&self, error: &anyhow::Error) -> RetryDecision {
133        let category = classify_anyhow_error(error);
134        RetryDecision {
135            category,
136            retryable: category.is_retryable(),
137            delay: None,
138            retry_after: None,
139        }
140    }
141
142    /// Classify an HTTP status code for retry eligibility.
143    ///
144    /// Attempt-agnostic, like [`Self::classify_anyhow`].
145    pub fn classify_status(&self, status: u16) -> RetryDecision {
146        let category = match status {
147            429 => ErrorCategory::RateLimit,
148            500 | 502 | 504 => ErrorCategory::Network,
149            503 => ErrorCategory::ServiceUnavailable,
150            401 | 403 => ErrorCategory::Authentication,
151            _ => ErrorCategory::ExecutionError,
152        };
153        RetryDecision {
154            category,
155            retryable: category.is_retryable(),
156            delay: None,
157            retry_after: None,
158        }
159    }
160}
161
162impl Default for RetryPolicy {
163    fn default() -> Self {
164        Self::from_retries(2, Duration::from_secs(1), Duration::from_secs(60), 2.0)
165    }
166}
167
168/// Result of classifying a failure for retry handling.
169#[derive(Debug, Clone, PartialEq, Eq)]
170#[must_use = "retry decisions must be handled before another attempt"]
171pub struct RetryDecision {
172    pub category: ErrorCategory,
173    pub retryable: bool,
174    pub delay: Option<Duration>,
175    pub retry_after: Option<Duration>,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn default_policy_allows_two_retries() {
184        let policy = RetryPolicy::default();
185        assert_eq!(policy.max_attempts, 3);
186        assert_eq!(policy.initial_delay, Duration::from_secs(1));
187        assert_eq!(policy.max_delay, Duration::from_secs(60));
188    }
189
190    #[test]
191    fn classify_status_rate_limit() {
192        let policy = RetryPolicy::default();
193        let decision = policy.classify_status(429);
194        assert!(decision.retryable);
195        assert_eq!(decision.category, ErrorCategory::RateLimit);
196    }
197
198    #[test]
199    fn classify_status_server_error() {
200        let policy = RetryPolicy::default();
201        let decision = policy.classify_status(503);
202        assert!(decision.retryable);
203        assert_eq!(decision.category, ErrorCategory::ServiceUnavailable);
204    }
205
206    #[test]
207    fn classify_status_auth_not_retryable() {
208        let policy = RetryPolicy::default();
209        let decision = policy.classify_status(401);
210        assert!(!decision.retryable);
211        assert_eq!(decision.category, ErrorCategory::Authentication);
212    }
213
214    #[test]
215    fn classify_status_matrix_pins_transport_retry_boundaries() {
216        // The `send_with_retry` helper and the updater's 401→fallback (which
217        // must not back off) both depend on this exact boundary: only
218        // 429/5xx-transient retries; every 4xx fails fast.
219        let policy = RetryPolicy::default();
220        for status in [429, 500, 502, 503, 504] {
221            assert!(policy.classify_status(status).retryable, "HTTP {status} must retry");
222        }
223        for status in [400, 401, 403, 404, 422] {
224            assert!(!policy.classify_status(status).retryable, "HTTP {status} must fail fast");
225        }
226    }
227
228    #[test]
229    fn classify_anyhow_network_error() {
230        let policy = RetryPolicy::default();
231        let err = anyhow::anyhow!("connection refused");
232        let decision = policy.classify_anyhow(&err);
233        assert!(decision.retryable);
234    }
235
236    #[test]
237    fn simple_policy_matches_bit_shift_doubling() {
238        // Parity with the historical `base_ms << attempt` curve used by
239        // wire clients before consolidation.
240        let policy = RetryPolicy::simple(10, 1000, 5000);
241        let legacy = |attempt: u32| -> u64 { 1000u64.saturating_mul(1u64 << attempt.min(16)).min(5000) };
242        for attempt in 0..6 {
243            assert_eq!(
244                policy.delay_for_attempt(attempt),
245                Duration::from_millis(legacy(attempt)),
246                "delay mismatch at attempt {attempt}"
247            );
248        }
249    }
250
251    #[test]
252    fn delay_for_attempt_clamps_overflowing_backoff_to_max_delay() {
253        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), f64::MAX);
254
255        assert_eq!(policy.delay_for_attempt(2), Duration::from_secs(8));
256    }
257
258    #[test]
259    fn delay_for_attempt_ignores_non_finite_jitter() {
260        let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
261        policy.jitter = f64::INFINITY;
262
263        assert_eq!(policy.delay_for_attempt(1), Duration::from_secs(2));
264    }
265
266    #[test]
267    fn delay_for_attempt_handles_huge_finite_jitter() {
268        let mut policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
269        policy.jitter = f64::MAX;
270
271        assert!(policy.delay_for_attempt(1) >= Duration::from_secs(2));
272    }
273
274    #[test]
275    fn decision_for_category_respects_attempt_budget() {
276        let policy = RetryPolicy::from_retries(1, Duration::from_secs(1), Duration::from_secs(8), 2.0);
277
278        let first = policy.decision_for_category(ErrorCategory::Network, 0, None);
279        assert!(first.retryable);
280        assert_eq!(first.delay, Some(Duration::from_secs(1)));
281
282        let exhausted = policy.decision_for_category(ErrorCategory::Network, 1, None);
283        assert!(!exhausted.retryable);
284        assert!(exhausted.delay.is_none());
285    }
286
287    #[test]
288    fn decision_for_category_prefers_retry_after() {
289        let policy = RetryPolicy::from_retries(3, Duration::from_secs(1), Duration::from_secs(8), 2.0);
290
291        let decision = policy.decision_for_category(ErrorCategory::RateLimit, 0, Some(Duration::from_secs(7)));
292        assert!(decision.retryable);
293        assert_eq!(decision.delay, Some(Duration::from_secs(7)));
294        assert_eq!(decision.retry_after, Some(Duration::from_secs(7)));
295    }
296
297    #[test]
298    fn with_jitter_applies_a_deterministic_offset() {
299        let plain = RetryPolicy::from_retries(3, Duration::from_millis(1000), Duration::from_secs(30), 2.0);
300        let jittered = plain.with_jitter(RetryPolicy::DEFAULT_JITTER);
301        // Attempt 0 draws a zero offset, so the base is unchanged; attempt 1
302        // shifts by exactly 31ms while the jitter-free curve does not move.
303        assert_eq!(jittered.delay_for_attempt(0), Duration::from_millis(1000));
304        assert_eq!(jittered.delay_for_attempt(1), Duration::from_millis(2031));
305        assert_eq!(plain.delay_for_attempt(1), Duration::from_millis(2000));
306        // Deterministic: the same attempt always draws the same delay.
307        assert_eq!(jittered.delay_for_attempt(1), jittered.delay_for_attempt(1));
308    }
309
310    #[test]
311    fn extreme_attempts_saturate_at_the_cap() {
312        let policy = RetryPolicy::from_retries(3, Duration::from_millis(1000), Duration::from_millis(5000), 2.0)
313            .with_jitter(RetryPolicy::DEFAULT_JITTER);
314        // Without saturation the `u32`-to-`i32` exponent would wrap and the
315        // delay would collapse instead of capping.
316        for attempt in [100, 10_000, u32::MAX] {
317            let delay = policy.delay_for_attempt(attempt);
318            assert!(
319                (Duration::from_millis(5000)..=Duration::from_millis(5750)).contains(&delay),
320                "attempt {attempt} escaped the cap: {delay:?}"
321            );
322        }
323    }
324}