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
4//! The two controls that stop a verification-gated route without reconfiguring it.
5//!
6//! Both resolve the same way when they fire: the turn escalates to the capable
7//! tier without producing an attempt. That is the direction this router already
8//! fails in, so neither control can make a turn less safe — only more expensive.
9//!
10//! [`KillSwitch`] is the operator's manual stop, flippable while the route is
11//! serving. [`CircuitBreaker`] is the automatic one, for a local endpoint that
12//! has stopped answering: without it every request pays a fresh failed call, and
13//! the decision budget is spent on a tier that cannot answer.
14
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::time::{Duration, Instant};
18
19use parking_lot::Mutex;
20
21use crate::LibsyError;
22
23/// An operator's runtime stop for a verification-gated route.
24///
25/// Cloneable, and every clone observes the same flag, so an operator can hold a
26/// handle while the router holds another. Engaging it takes effect on the next
27/// turn: no local attempt is produced and no verifier is consulted.
28///
29/// Distinct from [`ServingMode::Off`](super::config::ServingMode::Off), which is
30/// the same behaviour chosen at construction. This one can be flipped without
31/// rebuilding the route, which is what an incident needs.
32#[derive(Clone, Debug, Default)]
33pub struct KillSwitch(Arc<AtomicBool>);
34
35impl KillSwitch {
36    /// A switch that is not engaged.
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Stops local commits from this point on.
42    pub fn engage(&self) {
43        self.0.store(true, Ordering::Relaxed);
44    }
45
46    /// Allows local commits again.
47    pub fn release(&self) {
48        self.0.store(false, Ordering::Relaxed);
49    }
50
51    /// Whether local commits are currently stopped.
52    pub fn is_engaged(&self) -> bool {
53        self.0.load(Ordering::Relaxed)
54    }
55}
56
57/// How the breaker is tuned.
58#[derive(Clone, Copy, Debug)]
59pub struct BreakerConfig {
60    /// Consecutive local-tier failures that open the circuit.
61    pub threshold: u32,
62    /// How long the circuit stays open before one trial request is allowed.
63    pub cooldown: Duration,
64}
65
66impl Default for BreakerConfig {
67    fn default() -> Self {
68        Self {
69            threshold: 5,
70            cooldown: Duration::from_secs(30),
71        }
72    }
73}
74
75/// Mutable breaker state, held behind one lock so the fields cannot disagree.
76#[derive(Debug, Default)]
77struct BreakerState {
78    /// Local-tier failures since the last success.
79    consecutive_failures: u32,
80    /// When the circuit opened, if it is open.
81    opened_at: Option<Instant>,
82    /// Whether a half-open trial has been admitted and has not reported back.
83    ///
84    /// Without this, every caller arriving after the cooldown expires would be
85    /// admitted at once, so a dead endpoint would be hit by the whole concurrent
86    /// load rather than by one probe.
87    trial_in_flight: bool,
88}
89
90/// Stops calling an endpoint that has failed repeatedly.
91///
92/// Counts only *consecutive* failures, so an endpoint that is merely lossy never
93/// trips: one success resets the count. After the cooldown the circuit admits a
94/// single trial request, and a failure of that trial re-opens it immediately
95/// rather than starting the count over.
96#[derive(Debug)]
97pub(super) struct CircuitBreaker {
98    config: BreakerConfig,
99    state: Mutex<BreakerState>,
100}
101
102impl CircuitBreaker {
103    /// A closed breaker with the given tuning.
104    pub(super) fn new(config: BreakerConfig) -> Self {
105        Self {
106            config,
107            state: Mutex::new(BreakerState::default()),
108        }
109    }
110
111    /// Whether the endpoint should be skipped for this turn.
112    ///
113    /// Admitting the half-open trial is a write, so this is not a read-only
114    /// predicate: the same call that observes the cooldown expiring claims the
115    /// trial, and every concurrent caller keeps seeing an open circuit until
116    /// that trial reports back through [`CircuitBreaker::record_success`] or
117    /// [`CircuitBreaker::record_failure`].
118    pub(super) fn is_open(&self) -> bool {
119        let mut state = self.state.lock();
120        if state.opened_at.is_none() {
121            return false;
122        }
123        // The circuit stays open while a trial is out, however long ago it
124        // started: one probe at a time, not one per cooldown period.
125        if state.trial_in_flight {
126            return true;
127        }
128        let cooled = state
129            .opened_at
130            .is_some_and(|opened_at| opened_at.elapsed() >= self.config.cooldown);
131        if !cooled {
132            return true;
133        }
134        state.trial_in_flight = true;
135        false
136    }
137
138    /// Records a local-tier call that answered.
139    pub(super) fn record_success(&self) {
140        let mut state = self.state.lock();
141        state.consecutive_failures = 0;
142        state.opened_at = None;
143        state.trial_in_flight = false;
144    }
145
146    /// Records a local-tier call that failed, opening the circuit at the threshold.
147    ///
148    /// A failure while open is a failed half-open trial, which restarts the
149    /// cooldown rather than admitting another probe immediately.
150    pub(super) fn record_failure(&self) {
151        let mut state = self.state.lock();
152        state.consecutive_failures += 1;
153        state.trial_in_flight = false;
154        if state.consecutive_failures >= self.config.threshold {
155            state.opened_at = Some(Instant::now());
156        }
157    }
158}
159
160/// Whether a failed local call says anything about the endpoint's health.
161///
162/// Only transport failures, timeouts, and gateway-unavailable responses count.
163/// Request, capability, configuration, and other completed client failures prove
164/// that the endpoint answered; cancellation drops the call future before this
165/// predicate can record an outcome.
166pub(super) fn indicates_endpoint_failure(error: &LibsyError) -> bool {
167    indicates_transport_unavailability(error)
168}
169
170/// Whether a turn-judge failure proves that its local endpoint is unavailable.
171///
172/// Unlike the terminal commit gate, in-flight verification is fail-open on a
173/// judge error. Only transport, timeout, and gateway-unavailable failures are
174/// strong enough to override that and latch cloud.
175pub(super) fn indicates_transport_unavailability(error: &LibsyError) -> bool {
176    match error {
177        LibsyError::CircuitOpen { .. } | LibsyError::VgrTiersUnavailable { .. } => true,
178        LibsyError::ClientCall { source, .. } => match source {
179            switchyard_protocol::LlmClientError::Transport { .. }
180            | switchyard_protocol::LlmClientError::Timeout { .. } => true,
181            switchyard_protocol::LlmClientError::UpstreamHttp { status, .. } => matches!(
182                *status,
183                http::StatusCode::BAD_GATEWAY
184                    | http::StatusCode::SERVICE_UNAVAILABLE
185                    | http::StatusCode::GATEWAY_TIMEOUT
186            ),
187            _ => false,
188        },
189        _ => false,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use switchyard_protocol::{LlmClientError, ModelId};
197
198    fn client_call(source: LlmClientError) -> LibsyError {
199        LibsyError::ClientCall {
200            target: ModelId::new("local"),
201            source,
202        }
203    }
204
205    /// A breaker that opens on two failures, with a cooldown short enough to wait out.
206    fn breaker() -> CircuitBreaker {
207        CircuitBreaker::new(BreakerConfig {
208            threshold: 2,
209            cooldown: Duration::from_millis(20),
210        })
211    }
212
213    #[test]
214    fn a_lossy_endpoint_never_trips_the_breaker() {
215        // Only consecutive failures count, so alternating outcomes must not open
216        // the circuit however long they go on.
217        let breaker = breaker();
218        for _ in 0..10 {
219            breaker.record_failure();
220            breaker.record_success();
221        }
222        assert!(!breaker.is_open());
223    }
224
225    #[test]
226    fn the_circuit_opens_at_the_threshold_and_admits_one_trial_after_the_cooldown() {
227        let breaker = breaker();
228        breaker.record_failure();
229        assert!(!breaker.is_open(), "one failure is below the threshold");
230        breaker.record_failure();
231        assert!(breaker.is_open());
232
233        std::thread::sleep(Duration::from_millis(30));
234        // The trial is admitted once...
235        assert!(!breaker.is_open());
236        // ...and a single failure of that trial re-opens the circuit, rather
237        // than the count starting over from zero.
238        breaker.record_failure();
239        assert!(breaker.is_open());
240    }
241
242    #[test]
243    fn only_one_caller_is_admitted_per_half_open_trial() {
244        // The point of half-open is to probe with a single request. If every
245        // caller arriving after the cooldown were admitted, a dead endpoint
246        // would take the full concurrent load once per cooldown.
247        let breaker = breaker();
248        breaker.record_failure();
249        breaker.record_failure();
250        std::thread::sleep(Duration::from_millis(30));
251
252        assert!(!breaker.is_open(), "the first caller takes the trial");
253        for _ in 0..5 {
254            assert!(breaker.is_open(), "a trial is already in flight");
255        }
256
257        // The trial reporting back is what releases the circuit either way.
258        breaker.record_success();
259        assert!(!breaker.is_open());
260    }
261
262    #[test]
263    fn a_failed_trial_restarts_the_cooldown_rather_than_admitting_another() {
264        let breaker = breaker();
265        breaker.record_failure();
266        breaker.record_failure();
267        std::thread::sleep(Duration::from_millis(30));
268        assert!(!breaker.is_open());
269
270        breaker.record_failure();
271        // Re-opened, and the fresh cooldown has not elapsed.
272        assert!(breaker.is_open());
273    }
274
275    #[test]
276    fn a_successful_trial_closes_the_circuit() {
277        let breaker = breaker();
278        breaker.record_failure();
279        breaker.record_failure();
280        std::thread::sleep(Duration::from_millis(30));
281        assert!(!breaker.is_open());
282        breaker.record_success();
283        breaker.record_failure();
284        assert!(!breaker.is_open(), "the count restarted from the success");
285    }
286
287    #[test]
288    fn request_and_configuration_failures_are_not_endpoint_failures() {
289        let failures = [
290            client_call(LlmClientError::ContextWindowExceeded {
291                model: ModelId::new("local"),
292                message: "too long".to_string(),
293            }),
294            client_call(LlmClientError::UpstreamHttp {
295                status: http::StatusCode::BAD_REQUEST,
296                body: "invalid client request".to_string(),
297            }),
298            client_call(LlmClientError::UpstreamHttp {
299                status: http::StatusCode::UNAUTHORIZED,
300                body: "invalid credential".to_string(),
301            }),
302            client_call(LlmClientError::UpstreamHttp {
303                status: http::StatusCode::FORBIDDEN,
304                body: "request forbidden".to_string(),
305            }),
306            client_call(LlmClientError::UpstreamHttp {
307                status: http::StatusCode::REQUEST_TIMEOUT,
308                body: "client request timeout".to_string(),
309            }),
310            client_call(LlmClientError::UpstreamHttp {
311                status: http::StatusCode::TOO_MANY_REQUESTS,
312                body: "rate limited".to_string(),
313            }),
314            client_call(LlmClientError::UpstreamHttp {
315                status: http::StatusCode::BAD_REQUEST,
316                body: "image input is not supported by this model".to_string(),
317            }),
318            client_call(LlmClientError::InvalidRequest {
319                message: "unsupported request".to_string(),
320            }),
321            client_call(LlmClientError::RequestEncoding(
322                "unsupported capability".to_string(),
323            )),
324            client_call(LlmClientError::Configuration {
325                message: "missing target configuration".to_string(),
326            }),
327            LibsyError::NoTargets,
328        ];
329
330        for failure in failures {
331            assert!(
332                !indicates_endpoint_failure(&failure),
333                "{failure} must not affect endpoint health"
334            );
335        }
336    }
337
338    #[test]
339    fn transport_failures_are_endpoint_failures() {
340        let failures = [
341            client_call(LlmClientError::Transport {
342                source: std::io::Error::other("connection refused").into(),
343            }),
344            client_call(LlmClientError::Timeout {
345                source: std::io::Error::other("request timed out").into(),
346            }),
347            client_call(LlmClientError::UpstreamHttp {
348                status: http::StatusCode::BAD_GATEWAY,
349                body: "bad gateway".to_string(),
350            }),
351            client_call(LlmClientError::UpstreamHttp {
352                status: http::StatusCode::SERVICE_UNAVAILABLE,
353                body: "unavailable".to_string(),
354            }),
355            client_call(LlmClientError::UpstreamHttp {
356                status: http::StatusCode::GATEWAY_TIMEOUT,
357                body: "gateway timeout".to_string(),
358            }),
359        ];
360
361        for failure in failures {
362            assert!(
363                indicates_endpoint_failure(&failure),
364                "{failure} must affect endpoint health"
365            );
366        }
367    }
368
369    #[test]
370    fn a_kill_switch_is_shared_by_its_clones() {
371        // The operator's handle and the router's handle must be the same flag.
372        let operator = KillSwitch::new();
373        let router = operator.clone();
374        assert!(!router.is_engaged());
375        operator.engage();
376        assert!(router.is_engaged());
377        operator.release();
378        assert!(!router.is_engaged());
379    }
380}