use std::time::Duration;
use super::client::IntelError;
use super::endpoints::EndpointList;
use super::health::{BreakerTransition, ErrKind};
use crate::wire::intel::{Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailoverClass {
Failover(ErrKind),
Fatal,
}
pub fn classify(err: &IntelError) -> FailoverClass {
match err {
IntelError::Transport(e) => {
use std::io::ErrorKind::*;
let kind = match e.kind() {
ConnectionRefused => ErrKind::Refused,
ConnectionReset | ConnectionAborted | BrokenPipe => ErrKind::Reset,
TimedOut | WouldBlock => ErrKind::Timeout,
_ => ErrKind::Refused, };
FailoverClass::Failover(kind)
}
IntelError::Http(code, _) => match *code {
500 | 502 | 503 | 504 => FailoverClass::Failover(ErrKind::Http5xx),
429 => FailoverClass::Failover(ErrKind::Http429),
c if (500..600).contains(&c) => FailoverClass::Failover(ErrKind::Http5xx),
_ => FailoverClass::Fatal, },
IntelError::Parse(_) => FailoverClass::Fatal,
IntelError::Unsupported(_) => FailoverClass::Fatal,
IntelError::AllEndpointsDown(_) => FailoverClass::Fatal,
}
}
pub fn is_auth(err: &IntelError) -> bool {
matches!(err, IntelError::Http(401 | 403, _))
}
pub fn is_transient_status(code: u16) -> bool {
code == 429 || (500..600).contains(&code)
}
pub struct SweepResult {
pub outcome: Result<Response, IntelError>,
pub failover: Option<(usize, usize)>,
pub breaker_changes: Vec<(usize, BreakerTransition)>,
pub active_change: Option<usize>,
pub served_by: Option<usize>,
}
pub fn complete_resilient(
list: &mut EndpointList,
req: &Request,
timeout: Duration,
trace_id: Option<&str>,
) -> SweepResult {
let order = list.attempt_order();
let cfg = *list.breaker_config();
let mut breaker_changes = Vec::new();
let mut failover = None;
let mut last_err: Option<IntelError> = None;
let mut prev_idx: Option<usize> = None;
if order.is_empty() {
return SweepResult {
outcome: Err(IntelError::AllEndpointsDown(None)),
failover: None,
breaker_changes,
active_change: None,
served_by: None,
};
}
for idx in order {
if let Some(prev) = prev_idx
&& prev != idx
{
failover = Some((prev, idx));
}
prev_idx = Some(idx);
match list.ep(idx).complete_once(req, timeout, trace_id) {
Ok((resp, latency)) => {
if let Some(t) = list.ep(idx).health.record_success(latency) {
breaker_changes.push((idx, t));
}
let mut active_change = list.set_active(idx);
if let Some(snapped) = list.prefer_lowest_healthy() {
active_change = Some(snapped);
}
return SweepResult {
outcome: Ok(resp),
failover,
breaker_changes,
active_change,
served_by: Some(idx),
};
}
Err(e) => match classify(&e) {
FailoverClass::Failover(kind) => {
if let Some(t) = list.ep(idx).health.record_failure(kind, &cfg) {
breaker_changes.push((idx, t));
}
last_err = Some(e);
continue; }
FailoverClass::Fatal => {
return SweepResult {
outcome: Err(e),
failover,
breaker_changes,
active_change: None,
served_by: None,
};
}
},
}
}
SweepResult {
outcome: Err(IntelError::AllEndpointsDown(last_err.map(Box::new))),
failover,
breaker_changes,
active_change: None,
served_by: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
fn io_err(kind: io::ErrorKind) -> IntelError {
IntelError::Transport(io::Error::new(kind, "x"))
}
#[test]
fn transport_errors_are_failover_class() {
assert!(matches!(
classify(&io_err(io::ErrorKind::ConnectionRefused)),
FailoverClass::Failover(ErrKind::Refused)
));
assert!(matches!(
classify(&io_err(io::ErrorKind::TimedOut)),
FailoverClass::Failover(ErrKind::Timeout)
));
assert!(matches!(
classify(&io_err(io::ErrorKind::ConnectionReset)),
FailoverClass::Failover(ErrKind::Reset)
));
}
#[test]
fn http_5xx_and_429_failover_but_4xx_does_not() {
assert!(matches!(
classify(&IntelError::Http(503, "x".into())),
FailoverClass::Failover(ErrKind::Http5xx)
));
assert!(matches!(
classify(&IntelError::Http(429, "x".into())),
FailoverClass::Failover(ErrKind::Http429)
));
assert_eq!(
classify(&IntelError::Http(401, "x".into())),
FailoverClass::Fatal
);
assert_eq!(
classify(&IntelError::Http(403, "x".into())),
FailoverClass::Fatal
);
assert_eq!(
classify(&IntelError::Http(400, "x".into())),
FailoverClass::Fatal
);
assert_eq!(
classify(&IntelError::Http(404, "x".into())),
FailoverClass::Fatal
);
}
#[test]
fn malformed_body_is_fatal_not_failover() {
assert_eq!(
classify(&IntelError::Parse("bad json".into())),
FailoverClass::Fatal
);
}
#[test]
fn auth_detection_distinguishes_from_all_down() {
assert!(is_auth(&IntelError::Http(401, "x".into())));
assert!(is_auth(&IntelError::Http(403, "x".into())));
assert!(!is_auth(&IntelError::Http(503, "x".into())));
assert!(!is_auth(&io_err(io::ErrorKind::ConnectionRefused)));
}
#[test]
fn transient_status_matches_the_failover_class_split() {
for c in [429, 500, 502, 503, 504, 599] {
assert!(is_transient_status(c), "{c} should be transient");
}
for c in [200, 400, 401, 403, 404, 418] {
assert!(!is_transient_status(c), "{c} should NOT be transient");
}
}
use std::io::{Read, Write};
use std::net::TcpListener;
fn serve_status(status: u16) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let _ = s.read(&mut buf); let body = if status == 200 {
r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#
} else {
r#"{"error":{"message":"boom"}}"#
};
let resp = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = s.write_all(resp.as_bytes());
let _ = s.flush();
}
});
format!("http://127.0.0.1:{port}")
}
fn serve_sequence(statuses: Vec<u16>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for status in statuses {
let Ok((mut s, _)) = listener.accept() else {
break;
};
let mut buf = [0u8; 2048];
let _ = s.read(&mut buf);
let body = if status == 200 {
r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#
} else {
r#"{"error":{"message":"boom"}}"#
};
let resp = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = s.write_all(resp.as_bytes());
let _ = s.flush();
}
});
format!("http://127.0.0.1:{port}")
}
fn dead_endpoint() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
format!("http://127.0.0.1:{port}")
}
fn req() -> Request {
Request {
model: "m".into(),
messages: vec![crate::wire::intel::Message::user("hi")],
tools: Vec::new(),
max_tokens: 16,
temperature: Some(0.0),
}
}
fn list_of(uris: &[String]) -> EndpointList {
EndpointList::parse_with_env(&uris.join(","), None, &|_| None).unwrap()
}
#[test]
fn connect_failure_advances_to_next_healthy_endpoint() {
let good = serve_status(200);
let mut list = list_of(&[dead_endpoint(), good]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(r.outcome.is_ok(), "sweep failed over to the healthy ep");
assert_eq!(r.served_by, Some(1));
assert_eq!(r.failover, Some((0, 1)));
}
#[test]
fn http_5xx_advances_to_next_endpoint() {
let bad = serve_status(503);
let good = serve_status(200);
let mut list = list_of(&[bad, good]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(r.outcome.is_ok());
assert_eq!(r.served_by, Some(1));
}
#[test]
fn http_4xx_does_not_failover() {
let bad = serve_status(400);
let good = serve_status(200);
let mut list = list_of(&[bad, good]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(matches!(r.outcome, Err(IntelError::Http(400, _))));
assert_eq!(r.served_by, None);
assert_eq!(r.failover, None);
}
#[test]
fn auth_401_does_not_failover() {
let bad = serve_status(401);
let good = serve_status(200);
let mut list = list_of(&[bad, good]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(matches!(r.outcome, Err(IntelError::Http(401, _))));
assert!(is_auth(&r.outcome.unwrap_err()));
}
#[test]
fn circuit_broken_endpoint_is_skipped() {
let good = serve_status(200);
let mut list = list_of(&[dead_endpoint(), good]);
let cfg = *list.breaker_config();
for _ in 0..3 {
list.ep(0).health.record_failure(ErrKind::Refused, &cfg);
}
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(r.outcome.is_ok());
assert_eq!(r.served_by, Some(1));
assert_eq!(r.failover, None, "broken ep was skipped, not failed-over");
}
#[test]
fn all_endpoints_down_yields_all_endpoints_down_error() {
let mut list = list_of(&[dead_endpoint(), dead_endpoint()]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(matches!(r.outcome, Err(IntelError::AllEndpointsDown(_))));
for _ in 0..3 {
let _ = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
}
assert!(list.all_down());
assert!(list.attempt_order().is_empty());
}
#[test]
fn transient_5xx_is_retried_on_the_same_endpoint() {
let ep = serve_sequence(vec![503, 200]);
let mut list = list_of(&[ep]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(r.outcome.is_ok(), "same-endpoint retry cleared the 503");
assert_eq!(r.served_by, Some(0));
assert_eq!(r.failover, None, "handled in place, not failed over");
}
#[test]
fn transient_429_is_retried_then_succeeds() {
let ep = serve_sequence(vec![429, 200]);
let mut list = list_of(&[ep]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(r.outcome.is_ok(), "429 rate-limit blip was retried");
assert_eq!(r.served_by, Some(0));
}
#[test]
fn non_transient_4xx_is_not_retried() {
let ep = serve_sequence(vec![400, 200]);
let mut list = list_of(&[ep]);
let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
assert!(
matches!(r.outcome, Err(IntelError::Http(400, _))),
"4xx must surface on the first dial, not be retried"
);
}
}