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}