switchyard_libsy/algorithms/vgr/
safety.rs1use 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#[derive(Clone, Debug, Default)]
33pub struct KillSwitch(Arc<AtomicBool>);
34
35impl KillSwitch {
36 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn engage(&self) {
43 self.0.store(true, Ordering::Relaxed);
44 }
45
46 pub fn release(&self) {
48 self.0.store(false, Ordering::Relaxed);
49 }
50
51 pub fn is_engaged(&self) -> bool {
53 self.0.load(Ordering::Relaxed)
54 }
55}
56
57#[derive(Clone, Copy, Debug)]
59pub struct BreakerConfig {
60 pub threshold: u32,
62 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#[derive(Debug, Default)]
77struct BreakerState {
78 consecutive_failures: u32,
80 opened_at: Option<Instant>,
82 trial_in_flight: bool,
88}
89
90#[derive(Debug)]
97pub(super) struct CircuitBreaker {
98 config: BreakerConfig,
99 state: Mutex<BreakerState>,
100}
101
102impl CircuitBreaker {
103 pub(super) fn new(config: BreakerConfig) -> Self {
105 Self {
106 config,
107 state: Mutex::new(BreakerState::default()),
108 }
109 }
110
111 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 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 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 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
160pub(super) fn indicates_endpoint_failure(error: &LibsyError) -> bool {
167 indicates_transport_unavailability(error)
168}
169
170pub(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 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 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 assert!(!breaker.is_open());
236 breaker.record_failure();
239 assert!(breaker.is_open());
240 }
241
242 #[test]
243 fn only_one_caller_is_admitted_per_half_open_trial() {
244 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 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 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 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}