Skip to main content

vtcode_core/tools/resilience/
circuit_breaker.rs

1use crate::types::CompactStr;
2use hashbrown::HashMap;
3use parking_lot::RwLock;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use vtcode_commons::ErrorCategory;
7
8use crate::metrics::MetricsCollector;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum CircuitState {
12    #[default]
13    Closed, // Normal operation, allow all calls
14    Open,     // Failing, reject all calls immediately
15    HalfOpen, // Testing, allow limited calls to check recovery
16}
17
18impl CircuitState {
19    /// Returns valid transitions from this state
20    #[inline]
21    const fn valid_transitions(&self) -> &'static [CircuitState] {
22        match self {
23            CircuitState::Closed => &[CircuitState::Open],
24            CircuitState::Open => &[CircuitState::HalfOpen],
25            CircuitState::HalfOpen => &[CircuitState::Closed, CircuitState::Open],
26        }
27    }
28
29    /// Check if transition to target state is valid
30    #[inline]
31    fn can_transition_to(&self, target: CircuitState) -> bool {
32        self.valid_transitions().contains(&target)
33    }
34}
35
36#[derive(Clone)]
37pub struct CircuitBreakerConfig {
38    pub failure_threshold: u32,
39    pub reset_timeout: Duration, // Initial/Base timeout
40    pub min_backoff: Duration,   // Minimum wait time
41    pub max_backoff: Duration,   // Maximum wait time
42    pub backoff_factor: f64,     // Multiplier (e.g., 2.0 for exponential)
43    /// Number of probe requests to allow through in half-open state.
44    /// The circuit closes only after all probes succeed.  Default: 1.
45    pub half_open_probe_count: u32,
46}
47
48impl Default for CircuitBreakerConfig {
49    fn default() -> Self {
50        Self {
51            failure_threshold: 7,
52            reset_timeout: Duration::from_secs(60),
53            min_backoff: Duration::from_secs(5),   // Start with 5s
54            max_backoff: Duration::from_secs(120), // Cap at 2m
55            backoff_factor: 2.0,
56            half_open_probe_count: 1,
57        }
58    }
59}
60
61#[derive(Debug, Clone, Default)]
62struct ToolCircuitState {
63    status: CircuitState,
64    failure_count: u32,
65    last_failure_time: Option<Instant>,
66    current_backoff: Duration,          // Current backoff duration for this tool
67    circuit_opened_at: Option<Instant>, // When circuit first opened (for diagnostics)
68    open_count: u32,                    // How many times circuit has opened
69    denied_requests: u32,
70    last_denied_at: Option<Instant>,
71    last_error_category: Option<ErrorCategory>,
72    /// Number of successful probe requests in half-open state.
73    half_open_successes: u32,
74}
75
76impl ToolCircuitState {
77    /// Transition to a new state with debug assertion for valid transitions
78    #[inline]
79    fn transition_to(&mut self, new_state: CircuitState) {
80        debug_assert!(
81            self.status.can_transition_to(new_state),
82            "Invalid circuit state transition: {:?} -> {:?}",
83            self.status,
84            new_state
85        );
86        self.status = new_state;
87    }
88
89    /// Reset state on successful recovery (from HalfOpen or Open)
90    #[inline]
91    fn reset_on_success(&mut self) {
92        self.status = CircuitState::Closed;
93        self.failure_count = 0;
94        self.last_failure_time = None;
95        self.current_backoff = Duration::ZERO;
96        self.circuit_opened_at = None;
97        self.last_error_category = None;
98    }
99}
100
101#[derive(Debug, Clone)]
102pub struct ToolCircuitDiagnostics {
103    pub tool_name: String,
104    pub status: CircuitState,
105    pub failure_count: u32,
106    pub current_backoff: Duration,
107    pub remaining_backoff: Option<Duration>,
108    pub opened_at: Option<Instant>,
109    pub open_count: u32,
110    pub is_open: bool,
111    pub denied_requests: u32,
112    pub last_denied_at: Option<Instant>,
113    pub last_error_category: Option<ErrorCategory>,
114}
115
116#[derive(Debug, Clone, Default)]
117pub struct CircuitBreakerSnapshot {
118    pub diagnostics: Vec<ToolCircuitDiagnostics>,
119    pub open_circuits: Vec<String>,
120    pub open_count: usize,
121}
122
123/// Per-tool circuit breaker that tracks failure state independently for each tool.
124/// This prevents one misbehaving tool from disabling all tools in the system.
125///
126/// Uses `parking_lot::RwLock` for better concurrent access:
127/// - Read operations (allow_request, state checks) can proceed in parallel
128/// - Write operations (record_success/failure) acquire exclusive access
129/// - `parking_lot` is more efficient for short critical sections than std::Mutex
130#[derive(Clone)]
131pub struct CircuitBreaker {
132    /// Per-tool state tracking with RwLock for better read concurrency
133    tool_states: Arc<RwLock<HashMap<CompactStr, ToolCircuitState>>>,
134    config: CircuitBreakerConfig,
135    metrics: Option<Arc<MetricsCollector>>,
136}
137
138impl CircuitBreaker {
139    pub fn new(config: CircuitBreakerConfig) -> Self {
140        Self::build(config, None)
141    }
142
143    pub fn with_metrics(config: CircuitBreakerConfig, metrics: Arc<MetricsCollector>) -> Self {
144        Self::build(config, Some(metrics))
145    }
146
147    fn build(config: CircuitBreakerConfig, metrics: Option<Arc<MetricsCollector>>) -> Self {
148        Self {
149            tool_states: Arc::new(RwLock::new(HashMap::new())),
150            config,
151            metrics,
152        }
153    }
154
155    #[inline]
156    fn record_half_open_metric(&self) {
157        MetricsCollector::record_circuit_breaker_metrics(&self.metrics, true, false, false);
158    }
159
160    #[inline]
161    fn record_breaker_denial_metric(&self) {
162        MetricsCollector::record_circuit_breaker_metrics(&self.metrics, false, true, false);
163    }
164
165    #[inline]
166    fn record_circuit_open_metric(&self) {
167        MetricsCollector::record_circuit_breaker_metrics(&self.metrics, false, false, true);
168    }
169
170    /// Check if a request for a specific tool is allowed to proceed.
171    /// Returns true if allowed, false if the circuit is open for this tool.
172    ///
173    /// Uses optimistic read-first approach:
174    /// 1. Try read lock first (allows concurrent reads)
175    /// 2. Only upgrade to write lock if state transition is needed
176    pub fn allow_request_for_tool(&self, tool_name: &str) -> bool {
177        {
178            let states = self.tool_states.read();
179            if let Some(state) = states.get(tool_name) {
180                match state.status {
181                    CircuitState::Closed => return true,
182                    CircuitState::HalfOpen => {
183                        // Allow probe requests only if we haven't reached the probe limit.
184                        if state.half_open_successes < self.config.half_open_probe_count {
185                            return true;
186                        }
187                        return false;
188                    }
189                    CircuitState::Open => {
190                        if let Some(last_failure) = state.last_failure_time {
191                            let backoff = if state.current_backoff == Duration::ZERO {
192                                self.config.reset_timeout
193                            } else {
194                                state.current_backoff
195                            };
196                            if last_failure.elapsed() >= backoff {
197                                // Fall through to the write lock so we can transition to HalfOpen.
198                            }
199                        }
200                    }
201                }
202            } else {
203                return true;
204            }
205        }
206
207        let mut states = self.tool_states.write();
208        let state = states.entry(CompactStr::from(tool_name)).or_default();
209
210        match state.status {
211            CircuitState::Closed => true,
212            CircuitState::HalfOpen => state.half_open_successes < self.config.half_open_probe_count,
213            CircuitState::Open => {
214                if let Some(last_failure) = state.last_failure_time {
215                    let backoff = if state.current_backoff == Duration::ZERO {
216                        self.config.reset_timeout
217                    } else {
218                        state.current_backoff
219                    };
220
221                    if last_failure.elapsed() >= backoff {
222                        state.transition_to(CircuitState::HalfOpen);
223                        state.half_open_successes = 0;
224                        self.record_half_open_metric();
225                        return true;
226                    }
227                }
228                state.denied_requests = state.denied_requests.saturating_add(1);
229                state.last_denied_at = Some(Instant::now());
230                self.record_breaker_denial_metric();
231                false
232            }
233        }
234    }
235
236    /// Get remaining backoff time for a tool (if Open)
237    pub fn remaining_backoff(&self, tool_name: &str) -> Option<Duration> {
238        let states = self.tool_states.read();
239        let state = states.get(tool_name)?;
240
241        if state.status == CircuitState::Open
242            && let Some(last) = state.last_failure_time
243        {
244            let backoff = state.current_backoff;
245            let elapsed = last.elapsed();
246            return backoff.checked_sub(elapsed);
247        }
248        None
249    }
250
251    /// Record success for a specific tool
252    ///
253    /// State transitions on success:
254    /// - HalfOpen -> Closed (probe succeeded)
255    /// - Closed -> Closed (reset failure count)
256    /// - Open -> Closed (forced recovery, e.g., manual reset)
257    pub fn record_success_for_tool(&self, tool_name: &str) {
258        let mut states = self.tool_states.write();
259        let state = states.entry(CompactStr::from(tool_name)).or_default();
260
261        match state.status {
262            CircuitState::HalfOpen => {
263                state.half_open_successes += 1;
264                // Close the circuit only after all probe requests succeed.
265                if state.half_open_successes >= self.config.half_open_probe_count {
266                    state.reset_on_success();
267                }
268            }
269            CircuitState::Closed => {
270                // Reset failure count on success if we want purely consecutive failures
271                state.failure_count = 0;
272            }
273            CircuitState::Open => {
274                // Should not happen theoretically unless race condition or forced reset
275                // Using direct assignment here since this is an exceptional recovery path
276                state.reset_on_success();
277            }
278        }
279    }
280
281    /// Record failure for a specific tool.
282    /// Non-retryable validation, policy, and permission failures are ignored.
283    ///
284    /// State transitions on failure:
285    /// - Closed -> Open (when threshold reached)
286    /// - HalfOpen -> Open (probe failed, increase backoff)
287    /// - Open -> Open (no change, just update timestamp)
288    pub fn record_failure_category_for_tool(&self, tool_name: &str, category: ErrorCategory) {
289        if !category.should_trip_circuit_breaker() {
290            tracing::debug!(
291                tool = %tool_name,
292                category = %category,
293                "Skipping circuit breaker failure accounting for non-circuit-breaking error"
294            );
295            return;
296        }
297
298        let mut states = self.tool_states.write();
299        let state = states.entry(CompactStr::from(tool_name)).or_default();
300        state.last_failure_time = Some(Instant::now());
301        state.last_error_category = Some(category);
302
303        match state.status {
304            CircuitState::Closed => {
305                state.failure_count += 1;
306                if state.failure_count >= self.config.failure_threshold {
307                    state.transition_to(CircuitState::Open);
308                    state.current_backoff = self.config.min_backoff;
309                    state.half_open_successes = 0;
310                    state.circuit_opened_at = Some(Instant::now());
311                    state.open_count += 1;
312                    self.record_circuit_open_metric();
313
314                    tracing::warn!(
315                        tool = %tool_name,
316                        failures = state.failure_count,
317                        backoff_sec = state.current_backoff.as_secs(),
318                        open_count = state.open_count,
319                        "Circuit breaker OPEN for tool"
320                    );
321                }
322            }
323            CircuitState::HalfOpen => {
324                // Probe failed, revert to Open and increase backoff
325                state.transition_to(CircuitState::Open);
326                state.half_open_successes = 0;
327                state.circuit_opened_at = Some(Instant::now());
328                state.open_count += 1;
329                // Exponential backoff
330                let next_backoff = state.current_backoff.as_secs_f64() * self.config.backoff_factor;
331                state.current_backoff = Duration::try_from_secs_f64(next_backoff)
332                    .unwrap_or(self.config.max_backoff)
333                    .min(self.config.max_backoff)
334                    .max(self.config.min_backoff);
335                self.record_circuit_open_metric();
336
337                tracing::warn!(
338                    tool = %tool_name,
339                    backoff_sec = state.current_backoff.as_secs(),
340                    open_count = state.open_count,
341                    "Circuit breaker re-OPENED (probe failed)"
342                );
343            }
344            CircuitState::Open => {
345                // Already open, just update time - backoff stays same until probe attempt
346            }
347        }
348    }
349
350    /// Convenience wrapper that maps a boolean argument error flag to the appropriate ErrorCategory.
351    pub fn record_failure_for_tool(&self, tool_name: &str, is_argument_error: bool) {
352        let category = if is_argument_error {
353            ErrorCategory::InvalidParameters
354        } else {
355            ErrorCategory::ExecutionError
356        };
357        self.record_failure_category_for_tool(tool_name, category);
358    }
359
360    /// Get the circuit state for a specific tool
361    pub fn state_for_tool(&self, tool_name: &str) -> CircuitState {
362        let states = self.tool_states.read();
363        states.get(tool_name).map(|s| s.status).unwrap_or(CircuitState::Closed)
364    }
365
366    /// Reset the circuit breaker state for a specific tool
367    pub fn reset_tool(&self, tool_name: &str) {
368        let mut states = self.tool_states.write();
369        states.remove(tool_name);
370    }
371
372    /// Reset all tool circuit breaker states
373    pub fn reset_all(&self) {
374        let mut states = self.tool_states.write();
375        states.clear();
376    }
377
378    /// Get list of tools with currently OPEN circuits
379    pub fn get_open_circuits(&self) -> Vec<String> {
380        self.snapshot().open_circuits
381    }
382
383    /// Get diagnostic information for a specific tool
384    pub fn get_diagnostics(&self, tool_name: &str) -> ToolCircuitDiagnostics {
385        self.snapshot()
386            .diagnostics
387            .into_iter()
388            .find(|diag| diag.tool_name == tool_name)
389            .unwrap_or_else(|| ToolCircuitDiagnostics {
390                tool_name: tool_name.to_string(),
391                status: CircuitState::Closed,
392                failure_count: 0,
393                current_backoff: Duration::ZERO,
394                remaining_backoff: None,
395                opened_at: None,
396                open_count: 0,
397                is_open: false,
398                denied_requests: 0,
399                last_denied_at: None,
400                last_error_category: None,
401            })
402    }
403
404    /// Get diagnostics for all tools
405    pub fn get_all_diagnostics(&self) -> Vec<ToolCircuitDiagnostics> {
406        self.snapshot().diagnostics
407    }
408
409    /// Get a full snapshot of circuit breaker state under a single read lock.
410    pub fn snapshot(&self) -> CircuitBreakerSnapshot {
411        let states = self.tool_states.read();
412        let diagnostics: Vec<ToolCircuitDiagnostics> = states
413            .iter()
414            .map(|(name, state)| {
415                let is_open = matches!(state.status, CircuitState::Open);
416                ToolCircuitDiagnostics {
417                    tool_name: name.to_string(),
418                    status: state.status,
419                    failure_count: state.failure_count,
420                    current_backoff: state.current_backoff,
421                    remaining_backoff: if is_open {
422                        state
423                            .last_failure_time
424                            .and_then(|last| state.current_backoff.checked_sub(last.elapsed()))
425                    } else {
426                        None
427                    },
428                    opened_at: state.circuit_opened_at,
429                    open_count: state.open_count,
430                    is_open,
431                    denied_requests: state.denied_requests,
432                    last_denied_at: state.last_denied_at,
433                    last_error_category: state.last_error_category,
434                }
435            })
436            .collect();
437
438        let open_circuits: Vec<String> = diagnostics
439            .iter()
440            .filter(|diag| diag.is_open)
441            .map(|diag| diag.tool_name.clone())
442            .collect();
443
444        CircuitBreakerSnapshot {
445            diagnostics,
446            open_count: open_circuits.len(),
447            open_circuits,
448        }
449    }
450
451    /// Check if recovery pause should be triggered based on open circuit count
452    pub fn should_pause_for_recovery(&self, max_open_circuits: usize) -> bool {
453        self.snapshot().open_count >= max_open_circuits
454    }
455
456    /// Get count of currently open circuits
457    pub fn open_circuit_count(&self) -> usize {
458        self.snapshot().open_count
459    }
460}
461
462impl Default for CircuitBreaker {
463    fn default() -> Self {
464        Self::new(CircuitBreakerConfig::default())
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::metrics::MetricsCollector;
472
473    #[test]
474    fn invalid_parameters_do_not_open_circuit() {
475        let breaker = CircuitBreaker::new(CircuitBreakerConfig { failure_threshold: 2, ..Default::default() });
476
477        breaker.record_failure_category_for_tool("read_file", ErrorCategory::InvalidParameters);
478        breaker.record_failure_category_for_tool("read_file", ErrorCategory::InvalidParameters);
479
480        assert_eq!(breaker.state_for_tool("read_file"), CircuitState::Closed);
481        assert_eq!(breaker.get_diagnostics("read_file").failure_count, 0);
482    }
483
484    #[test]
485    fn denied_requests_are_recorded_for_open_circuit() {
486        let breaker = CircuitBreaker::new(CircuitBreakerConfig {
487            failure_threshold: 1,
488            min_backoff: Duration::from_secs(30),
489            ..Default::default()
490        });
491
492        breaker.record_failure_category_for_tool("shell", ErrorCategory::ExecutionError);
493        assert_eq!(breaker.state_for_tool("shell"), CircuitState::Open);
494        assert!(!breaker.allow_request_for_tool("shell"));
495
496        let diagnostics = breaker.get_diagnostics("shell");
497        assert_eq!(diagnostics.denied_requests, 1);
498        assert!(diagnostics.last_denied_at.is_some());
499        assert_eq!(diagnostics.last_error_category, Some(ErrorCategory::ExecutionError));
500    }
501
502    #[test]
503    fn metrics_record_open_half_open_and_denials() {
504        let metrics = Arc::new(MetricsCollector::new());
505        let breaker = CircuitBreaker::with_metrics(
506            CircuitBreakerConfig {
507                failure_threshold: 1,
508                min_backoff: Duration::from_millis(10),
509                max_backoff: Duration::from_secs(1),
510                ..Default::default()
511            },
512            metrics.clone(),
513        );
514
515        breaker.record_failure_category_for_tool("shell", ErrorCategory::ExecutionError);
516        assert!(!breaker.allow_request_for_tool("shell"));
517
518        std::thread::sleep(Duration::from_millis(20));
519        assert!(breaker.allow_request_for_tool("shell"));
520
521        let execution = metrics.get_execution_metrics();
522        assert_eq!(execution.circuit_open_events, 1);
523        assert_eq!(execution.breaker_denials, 1);
524        assert_eq!(execution.half_open_events, 1);
525    }
526
527    #[test]
528    fn overflowing_half_open_backoff_clamps_to_max_backoff() {
529        let breaker = CircuitBreaker::new(CircuitBreakerConfig {
530            failure_threshold: 1,
531            min_backoff: Duration::from_millis(1),
532            max_backoff: Duration::from_millis(10),
533            backoff_factor: f64::MAX,
534            ..Default::default()
535        });
536
537        breaker.record_failure_category_for_tool("shell", ErrorCategory::ExecutionError);
538        std::thread::sleep(Duration::from_millis(2));
539        assert!(breaker.allow_request_for_tool("shell"));
540
541        breaker.record_failure_category_for_tool("shell", ErrorCategory::ExecutionError);
542
543        assert_eq!(breaker.get_diagnostics("shell").current_backoff, Duration::from_millis(10));
544    }
545
546    #[test]
547    fn concurrent_requests_do_not_cause_inconsistent_state() {
548        use std::sync::atomic::{AtomicUsize, Ordering};
549
550        let breaker = Arc::new(CircuitBreaker::new(CircuitBreakerConfig {
551            failure_threshold: 1,
552            min_backoff: Duration::from_millis(50),
553            ..Default::default()
554        }));
555
556        // Open the circuit for "tool_a"
557        breaker.record_failure_category_for_tool("tool_a", ErrorCategory::ExecutionError);
558        assert_eq!(breaker.state_for_tool("tool_a"), CircuitState::Open);
559
560        // Wait for backoff to expire with generous margin (2x the backoff) to avoid flaky tests
561        std::thread::sleep(Duration::from_millis(100));
562
563        // Spawn multiple threads that all try to make requests
564        let mut handles = vec![];
565        let request_count = Arc::new(AtomicUsize::new(0));
566
567        for _ in 0..10 {
568            let breaker = breaker.clone();
569            let request_count = request_count.clone();
570            handles.push(std::thread::spawn(move || {
571                if breaker.allow_request_for_tool("tool_a") {
572                    request_count.fetch_add(1, Ordering::SeqCst);
573                }
574            }));
575        }
576
577        for handle in handles {
578            handle.join().unwrap();
579        }
580
581        // The circuit should be in HalfOpen state
582        // All threads should see consistent state
583        let final_state = breaker.state_for_tool("tool_a");
584        assert!(
585            final_state == CircuitState::HalfOpen || final_state == CircuitState::Closed,
586            "Circuit should be in HalfOpen or Closed state, got {final_state:?}"
587        );
588
589        // At least one thread should have succeeded
590        let count = request_count.load(Ordering::SeqCst);
591        assert!(count >= 1, "At least one thread should have succeeded");
592    }
593
594    #[test]
595    fn concurrent_failures_across_different_tools_are_independent() {
596        let breaker =
597            Arc::new(CircuitBreaker::new(CircuitBreakerConfig { failure_threshold: 3, ..Default::default() }));
598
599        // Spawn threads that fail different tools
600        let mut handles = vec![];
601
602        for i in 0..10 {
603            let breaker = breaker.clone();
604            handles.push(std::thread::spawn(move || {
605                let tool_name = format!("tool_{}", i % 3);
606                for _ in 0..2 {
607                    breaker.record_failure_category_for_tool(&tool_name, ErrorCategory::ExecutionError);
608                }
609            }));
610        }
611
612        for handle in handles {
613            handle.join().unwrap();
614        }
615
616        // Each tool should have exactly 6 failures (2 failures * 3 threads per tool)
617        // but none should be open (threshold is 3, each thread does 2 failures)
618        // Actually, with 3 threads doing 2 failures each = 6 failures total per tool
619        // which exceeds threshold of 3, so they should be open
620        for i in 0..3 {
621            let tool_name = format!("tool_{i}");
622            let state = breaker.state_for_tool(&tool_name);
623            // With 6 failures per tool (exceeds threshold of 3), circuit should be open
624            assert_eq!(state, CircuitState::Open, "Tool {tool_name} should be Open after 6 failures");
625        }
626    }
627
628    #[test]
629    fn concurrent_successes_and_failures_maintain_consistency() {
630        use std::sync::atomic::{AtomicUsize, Ordering};
631
632        let breaker =
633            Arc::new(CircuitBreaker::new(CircuitBreakerConfig { failure_threshold: 5, ..Default::default() }));
634
635        let success_count = Arc::new(AtomicUsize::new(0));
636        let failure_count = Arc::new(AtomicUsize::new(0));
637
638        // Spawn threads that do both successes and failures
639        let mut handles = vec![];
640
641        for i in 0..20 {
642            let breaker = breaker.clone();
643            let success_count = success_count.clone();
644            let failure_count = failure_count.clone();
645            handles.push(std::thread::spawn(move || {
646                if i % 2 == 0 {
647                    breaker.record_success_for_tool("tool_x");
648                    success_count.fetch_add(1, Ordering::SeqCst);
649                } else {
650                    breaker.record_failure_category_for_tool("tool_x", ErrorCategory::ExecutionError);
651                    failure_count.fetch_add(1, Ordering::SeqCst);
652                }
653            }));
654        }
655
656        for handle in handles {
657            handle.join().unwrap();
658        }
659
660        // Verify that the circuit state is consistent
661        // With 10 successes and 10 failures, the state depends on ordering
662        // but it should be either Closed or Open, not in an invalid state
663        let final_state = breaker.state_for_tool("tool_x");
664        assert!(
665            final_state == CircuitState::Closed || final_state == CircuitState::Open,
666            "Circuit should be in a valid state, got {final_state:?}"
667        );
668
669        // Verify all operations were counted
670        let successes = success_count.load(Ordering::SeqCst);
671        let failures = failure_count.load(Ordering::SeqCst);
672        assert_eq!(successes, 10, "Should have 10 successes");
673        assert_eq!(failures, 10, "Should have 10 failures");
674    }
675}