Skip to main content

vtcode_core/tools/resilience/
mod.rs

1//! Resilience primitives for tool execution.
2//!
3//! Collects the concerns that govern how tools recover from and bound
4//! transient failures:
5//!
6//! - [`circuit_breaker`] - state-machine fault isolation (Closed/Open/HalfOpen)
7//!   keyed by tool name. Tracks failure counts, applies exponential backoff,
8//!   and short-circuits calls when a downstream is degraded.
9//! - [`adaptive_rate_limiter`] - per-key token-bucket rate limiter with adaptive
10//!   refill and priority weighting. Used to throttle tools whose cost varies
11//!   with the call.
12//! - [`rate_limiter`] - per-tool budgeted counter rate limiter. Provides the
13//!   per-tool token bucket that the executor uses for synchronous admission.
14//! - [`ToolResilience`] - the unified facade. New callers should use this
15//!   rather than reaching for the individual primitives, so that all three
16//!   invariants (admission, fault isolation, post-call accounting) are checked
17//!   in one place.
18//!
19//! The primitives share callers (autonomous executor, tool pipeline, agent
20//! error recovery) but address distinct failure modes; they are grouped here
21//! so a maintainer can audit the full resilience toolkit in one place.
22
23pub mod adaptive_rate_limiter;
24pub mod circuit_breaker;
25pub mod rate_limiter;
26
27use std::sync::Arc;
28use std::time::Duration;
29
30use once_cell::sync::Lazy;
31
32use vtcode_commons::ErrorCategory;
33
34use self::adaptive_rate_limiter::{AdaptiveRateLimiter, Priority};
35use self::circuit_breaker::CircuitBreaker;
36
37/// Outcome of a tool call, used by [`ToolResilience::record_outcome`] to update
38/// the circuit breaker.
39#[derive(Debug, Clone, Copy)]
40pub enum CallOutcome {
41    /// The call succeeded. Resets the failure counter for the tool.
42    Success,
43    /// The call failed due to a non-retryable error (invalid arguments, planning workflow
44    /// denial, permission denial). Does not trip the circuit breaker.
45    InvalidArgument,
46    /// The call failed due to a retryable execution error. May trip the circuit
47    /// breaker after `failure_threshold` consecutive occurrences.
48    ExecutionError,
49    /// The call failed due to cancellation (Drop guard, timeout, user abort).
50    /// Treated like an execution error for breaker accounting.
51    Cancelled,
52}
53
54impl CallOutcome {
55    /// Map this outcome to the `ErrorCategory` used by the circuit breaker for
56    /// non-success outcomes. Returns `None` for [`CallOutcome::Success`] because
57    /// success is handled by a separate code path (`record_success`).
58    fn to_error_category(self) -> Option<ErrorCategory> {
59        match self {
60            CallOutcome::Success => None,
61            CallOutcome::InvalidArgument => Some(ErrorCategory::InvalidParameters),
62            CallOutcome::ExecutionError | CallOutcome::Cancelled => Some(ErrorCategory::ExecutionError),
63        }
64    }
65}
66
67/// Unified facade for tool resilience. Wraps the adaptive rate limiter and the
68/// circuit breaker so callers can use a single API for admission, fault
69/// isolation, and post-call accounting.
70///
71/// # Example
72///
73/// ```ignore
74/// use vtcode_core::tools::resilience::{GLOBAL_TOOL_RESILIENCE, CallOutcome, Priority};
75///
76/// // On entry:
77/// GLOBAL_TOOL_RESILIENCE
78///     .try_acquire("read_file", Priority::Normal)
79///     .map_err(|wait| anyhow!("rate limited; retry after {wait:?}"))?;
80///
81/// // On exit:
82/// match result {
83///     Ok(_) => GLOBAL_TOOL_RESILIENCE.record_success("read_file"),
84///     Err(e) if e.is_argument_error() => {
85///         GLOBAL_TOOL_RESILIENCE.record_outcome("read_file", CallOutcome::InvalidArgument);
86///     }
87///     Err(_) => {
88///         GLOBAL_TOOL_RESILIENCE.record_outcome("read_file", CallOutcome::ExecutionError);
89///     }
90/// }
91/// ```
92pub struct ToolResilience {
93    rate_limiter: AdaptiveRateLimiter,
94    circuit_breaker: CircuitBreaker,
95}
96
97impl ToolResilience {
98    /// Construct a new facade with the supplied adaptive rate limiter and
99    /// circuit breaker.
100    pub fn new(rate_limiter: AdaptiveRateLimiter, circuit_breaker: CircuitBreaker) -> Self {
101        Self { rate_limiter, circuit_breaker }
102    }
103
104    /// Try to acquire a token for the tool. Returns `Ok(())` when the call is
105    /// allowed. When the tool is currently rate limited the suggested wait
106    /// duration is returned in `Err`.
107    pub fn try_acquire(&self, tool_name: &str, priority: Priority) -> Result<(), Duration> {
108        // 1. Fault isolation first: a tool with an open circuit is rejected
109        //    immediately, even if the rate limiter has tokens to spare.
110        if !self.circuit_breaker.allow_request_for_tool(tool_name) {
111            let backoff = self
112                .circuit_breaker
113                .remaining_backoff(tool_name)
114                .unwrap_or_else(|| Duration::from_millis(100));
115            return Err(backoff);
116        }
117        // 2. Rate limit. Configure the priority (idempotent).
118        self.rate_limiter.set_priority(tool_name, priority);
119        self.rate_limiter.try_acquire(tool_name)
120    }
121
122    /// Record a successful call. Closes the circuit if it was HalfOpen.
123    pub fn record_success(&self, tool_name: &str) {
124        self.circuit_breaker.record_success_for_tool(tool_name);
125    }
126
127    /// Record a non-success outcome. Categorical errors that should not trip
128    /// the breaker (`InvalidArgument`) are routed through
129    /// `CallOutcome::InvalidArgument`. See [`CallOutcome`].
130    pub fn record_outcome(&self, tool_name: &str, outcome: CallOutcome) {
131        match outcome.to_error_category() {
132            // Success: reset the breaker (also covers HalfOpen -> Closed).
133            None => self.circuit_breaker.record_success_for_tool(tool_name),
134            // Failure: the breaker API is a no-op for non-circuit-breaking
135            // categories, so InvalidArgument collapses to a harmless call.
136            Some(category) => self.circuit_breaker.record_failure_category_for_tool(tool_name, category),
137        }
138    }
139
140    /// Diagnostic snapshot of the circuit breaker.
141    pub fn circuit_snapshot(&self) -> circuit_breaker::CircuitBreakerSnapshot {
142        self.circuit_breaker.snapshot()
143    }
144}
145
146/// Process-wide resilience facade. Constructed lazily from the shared adaptive
147/// rate limiter and a default circuit breaker.
148pub static GLOBAL_TOOL_RESILIENCE: Lazy<Arc<ToolResilience>> =
149    Lazy::new(|| Arc::new(ToolResilience::new(AdaptiveRateLimiter::default(), CircuitBreaker::default())));
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn facade_records_success_and_failures() {
157        let resilience = ToolResilience::new(
158            AdaptiveRateLimiter::new(8.0, 4.0),
159            CircuitBreaker::new(circuit_breaker::CircuitBreakerConfig { failure_threshold: 2, ..Default::default() }),
160        );
161
162        // First two calls allowed; record two execution errors to open the circuit.
163        resilience.try_acquire("alpha", Priority::Normal).expect("first call allowed");
164        resilience.record_outcome("alpha", CallOutcome::ExecutionError);
165
166        resilience.try_acquire("alpha", Priority::Normal).expect("second call allowed");
167        resilience.record_outcome("alpha", CallOutcome::ExecutionError);
168
169        // Third call must be rejected by the circuit breaker.
170        let third = resilience.try_acquire("alpha", Priority::Normal);
171        assert!(third.is_err(), "circuit should be open after 2 failures");
172    }
173
174    #[test]
175    fn invalid_argument_does_not_trip_breaker() {
176        let resilience = ToolResilience::new(
177            AdaptiveRateLimiter::new(8.0, 4.0),
178            CircuitBreaker::new(circuit_breaker::CircuitBreakerConfig { failure_threshold: 1, ..Default::default() }),
179        );
180
181        for _ in 0..3 {
182            resilience.try_acquire("beta", Priority::Normal).expect("call allowed");
183            resilience.record_outcome("beta", CallOutcome::InvalidArgument);
184        }
185
186        // After 3 invalid-argument failures, the circuit must still be Closed.
187        assert!(resilience.try_acquire("beta", Priority::Normal).is_ok());
188    }
189}