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, Open, HalfOpen, }
17
18impl CircuitState {
19 #[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 #[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, pub min_backoff: Duration, pub max_backoff: Duration, pub backoff_factor: f64, 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), max_backoff: Duration::from_secs(120), 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, circuit_opened_at: Option<Instant>, open_count: u32, denied_requests: u32,
70 last_denied_at: Option<Instant>,
71 last_error_category: Option<ErrorCategory>,
72 half_open_successes: u32,
74}
75
76impl ToolCircuitState {
77 #[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 #[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#[derive(Clone)]
131pub struct CircuitBreaker {
132 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 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 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 }
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 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 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 if state.half_open_successes >= self.config.half_open_probe_count {
266 state.reset_on_success();
267 }
268 }
269 CircuitState::Closed => {
270 state.failure_count = 0;
272 }
273 CircuitState::Open => {
274 state.reset_on_success();
277 }
278 }
279 }
280
281 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 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 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 }
347 }
348 }
349
350 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 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 pub fn reset_tool(&self, tool_name: &str) {
368 let mut states = self.tool_states.write();
369 states.remove(tool_name);
370 }
371
372 pub fn reset_all(&self) {
374 let mut states = self.tool_states.write();
375 states.clear();
376 }
377
378 pub fn get_open_circuits(&self) -> Vec<String> {
380 self.snapshot().open_circuits
381 }
382
383 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 pub fn get_all_diagnostics(&self) -> Vec<ToolCircuitDiagnostics> {
406 self.snapshot().diagnostics
407 }
408
409 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 pub fn should_pause_for_recovery(&self, max_open_circuits: usize) -> bool {
453 self.snapshot().open_count >= max_open_circuits
454 }
455
456 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 breaker.record_failure_category_for_tool("tool_a", ErrorCategory::ExecutionError);
558 assert_eq!(breaker.state_for_tool("tool_a"), CircuitState::Open);
559
560 std::thread::sleep(Duration::from_millis(100));
562
563 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 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 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 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 for i in 0..3 {
621 let tool_name = format!("tool_{i}");
622 let state = breaker.state_for_tool(&tool_name);
623 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 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 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 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}