Skip to main content

switchyard_libsy/algorithms/vgr/
safety.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::{Duration, Instant};
7
8use http::StatusCode;
9use parking_lot::Mutex;
10use switchyard_protocol::LlmClientError;
11
12use crate::LibsyError;
13
14/// Operator-controlled stop for local attempts.
15#[derive(Clone, Debug, Default)]
16pub struct KillSwitch(Arc<AtomicBool>);
17
18impl KillSwitch {
19    /// Creates a released switch.
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Stops local attempts for subsequent requests.
25    pub fn engage(&self) {
26        self.0.store(true, Ordering::Relaxed);
27    }
28
29    /// Allows local attempts again.
30    pub fn release(&self) {
31        self.0.store(false, Ordering::Relaxed);
32    }
33
34    /// Reports whether local attempts are stopped.
35    pub fn is_engaged(&self) -> bool {
36        self.0.load(Ordering::Relaxed)
37    }
38}
39
40/// Circuit-breaker configuration.
41#[derive(Clone, Copy, Debug)]
42pub struct BreakerConfig {
43    /// Consecutive endpoint failures required to open.
44    pub threshold: u32,
45    /// Delay before one half-open trial is admitted.
46    pub cooldown: Duration,
47}
48
49impl Default for BreakerConfig {
50    fn default() -> Self {
51        Self {
52            threshold: 5,
53            cooldown: Duration::from_secs(30),
54        }
55    }
56}
57
58#[derive(Debug, Default)]
59struct BreakerState {
60    failures: u32,
61    opened_at: Option<Instant>,
62    trial_in_flight: bool,
63}
64
65#[derive(Debug)]
66pub(super) struct CircuitBreaker {
67    config: BreakerConfig,
68    state: Mutex<BreakerState>,
69}
70
71impl CircuitBreaker {
72    pub(super) fn new(config: BreakerConfig) -> Self {
73        Self {
74            config,
75            state: Mutex::new(BreakerState::default()),
76        }
77    }
78
79    pub(super) fn is_open(&self) -> bool {
80        let mut state = self.state.lock();
81        let Some(opened_at) = state.opened_at else {
82            return false;
83        };
84        if state.trial_in_flight || opened_at.elapsed() < self.config.cooldown {
85            return true;
86        }
87        state.trial_in_flight = true;
88        false
89    }
90
91    pub(super) fn success(&self) {
92        *self.state.lock() = BreakerState::default();
93    }
94
95    pub(super) fn failure(&self) {
96        let mut state = self.state.lock();
97        state.failures = state.failures.saturating_add(1);
98        state.trial_in_flight = false;
99        if state.failures >= self.config.threshold {
100            state.opened_at = Some(Instant::now());
101        }
102    }
103}
104
105/// Errors for which retrying on the capable tier is safe.
106pub(super) fn fallback_eligible(error: &LibsyError) -> bool {
107    matches!(
108        error,
109        LibsyError::ClientCall { source, .. } if match source {
110            LlmClientError::ContextWindowExceeded { .. }
111                | LlmClientError::Transport { .. }
112                | LlmClientError::Timeout { .. } => true,
113            LlmClientError::UpstreamHttp { status, .. } =>
114                matches!(
115                    *status,
116                    StatusCode::FORBIDDEN
117                        | StatusCode::REQUEST_TIMEOUT
118                        | StatusCode::TOO_MANY_REQUESTS
119                ) || status.is_server_error(),
120            _ => false,
121        }
122    )
123}
124
125/// Only endpoint unavailability contributes to breaker health.
126pub(super) fn endpoint_failure(error: &LibsyError) -> bool {
127    matches!(
128        error,
129        LibsyError::ClientCall { source, .. } if match source {
130            LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => true,
131            LlmClientError::UpstreamHttp { status, .. } => matches!(
132                *status,
133                StatusCode::BAD_GATEWAY
134                    | StatusCode::SERVICE_UNAVAILABLE
135                    | StatusCode::GATEWAY_TIMEOUT
136            ),
137            _ => false,
138        }
139    )
140}