Skip to main content

vtcode_commons/
retry.rs

1//! Minimal shared retry policy for wire-level HTTP clients.
2//!
3//! This module provides a lightweight [`RetryPolicy`] that classifies errors
4//! into retryable vs non-retryable categories. It is intentionally simpler
5//! than the full retry system in `vtcode-core` (which adds jitter, multipliers,
6//! tool-aware timeouts, and `VtCodeError` integration). Wire clients that only
7//! need "should I retry this HTTP call?" use this shared policy; richer retry
8//! loops keep their own domain-specific version.
9
10use crate::error_category::{ErrorCategory, classify_anyhow_error};
11
12/// Lightweight retry policy for HTTP wire clients.
13#[derive(Debug, Clone)]
14pub struct RetryPolicy {
15    /// Maximum number of retries (not counting the initial attempt).
16    pub max_retries: u32,
17    /// Base delay between retries in milliseconds.
18    pub base_delay_ms: u64,
19    /// Maximum delay cap in milliseconds.
20    pub max_delay_ms: u64,
21}
22
23impl Default for RetryPolicy {
24    fn default() -> Self {
25        Self {
26            max_retries: 3,
27            base_delay_ms: 1000,
28            max_delay_ms: 30_000,
29        }
30    }
31}
32
33/// Result of classifying a failure for retry handling.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RetryDecision {
36    /// Whether the operation should be retried.
37    pub retryable: bool,
38    /// The error category determined during classification.
39    pub category: ErrorCategory,
40}
41
42impl RetryPolicy {
43    /// Classify an `anyhow::Error` for retry eligibility.
44    ///
45    /// Uses the shared [`classify_anyhow_error`] classifier from
46    /// [`crate::error_category`].
47    pub fn classify_anyhow(&self, error: &anyhow::Error) -> RetryDecision {
48        let category = classify_anyhow_error(error);
49        RetryDecision {
50            retryable: category.is_retryable(),
51            category,
52        }
53    }
54
55    /// Classify an HTTP status code for retry eligibility.
56    pub fn classify_status(&self, status: u16) -> RetryDecision {
57        let category = match status {
58            429 => ErrorCategory::RateLimit,
59            500 | 502 | 504 => ErrorCategory::Network,
60            503 => ErrorCategory::ServiceUnavailable,
61            401 | 403 => ErrorCategory::Authentication,
62            _ => ErrorCategory::ExecutionError,
63        };
64        RetryDecision {
65            retryable: category.is_retryable(),
66            category,
67        }
68    }
69
70    /// Compute the backoff delay for a given attempt index (0-based).
71    ///
72    /// Returns the delay in milliseconds, capped at `max_delay_ms`.
73    pub fn delay_ms_for_attempt(&self, attempt: u32) -> u64 {
74        let delay = self.base_delay_ms.saturating_mul(1u64 << attempt.min(16));
75        delay.min(self.max_delay_ms)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn default_policy_has_reasonable_values() {
85        let policy = RetryPolicy::default();
86        assert_eq!(policy.max_retries, 3);
87        assert_eq!(policy.base_delay_ms, 1000);
88        assert_eq!(policy.max_delay_ms, 30_000);
89    }
90
91    #[test]
92    fn classify_status_rate_limit() {
93        let policy = RetryPolicy::default();
94        let decision = policy.classify_status(429);
95        assert!(decision.retryable);
96        assert_eq!(decision.category, ErrorCategory::RateLimit);
97    }
98
99    #[test]
100    fn classify_status_server_error() {
101        let policy = RetryPolicy::default();
102        let decision = policy.classify_status(503);
103        assert!(decision.retryable);
104        assert_eq!(decision.category, ErrorCategory::ServiceUnavailable);
105    }
106
107    #[test]
108    fn classify_status_auth_not_retryable() {
109        let policy = RetryPolicy::default();
110        let decision = policy.classify_status(401);
111        assert!(!decision.retryable);
112        assert_eq!(decision.category, ErrorCategory::Authentication);
113    }
114
115    #[test]
116    fn classify_anyhow_network_error() {
117        let policy = RetryPolicy::default();
118        let err = anyhow::anyhow!("connection refused");
119        let decision = policy.classify_anyhow(&err);
120        assert!(decision.retryable);
121    }
122
123    #[test]
124    fn delay_capped_at_max() {
125        let policy = RetryPolicy {
126            max_retries: 10,
127            base_delay_ms: 1000,
128            max_delay_ms: 5000,
129        };
130        assert_eq!(policy.delay_ms_for_attempt(0), 1000);
131        assert_eq!(policy.delay_ms_for_attempt(1), 2000);
132        assert_eq!(policy.delay_ms_for_attempt(2), 4000);
133        assert_eq!(policy.delay_ms_for_attempt(3), 5000); // capped
134        assert_eq!(policy.delay_ms_for_attempt(10), 5000); // still capped
135    }
136}