1use std::time::Duration;
17
18use super::client::IntelError;
19use super::endpoints::EndpointList;
20use super::health::{BreakerTransition, ErrKind};
21use crate::wire::intel::{Request, Response};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FailoverClass {
26 Failover(ErrKind),
28 Fatal,
31}
32
33pub fn classify(err: &IntelError) -> FailoverClass {
37 match err {
38 IntelError::Transport(e) => {
41 use std::io::ErrorKind::*;
42 let kind = match e.kind() {
43 ConnectionRefused => ErrKind::Refused,
44 ConnectionReset | ConnectionAborted | BrokenPipe => ErrKind::Reset,
45 TimedOut | WouldBlock => ErrKind::Timeout,
46 _ => ErrKind::Refused, };
48 FailoverClass::Failover(kind)
49 }
50 IntelError::Http(code, _) => match *code {
53 500 | 502 | 503 | 504 => FailoverClass::Failover(ErrKind::Http5xx),
54 429 => FailoverClass::Failover(ErrKind::Http429),
55 c if (500..600).contains(&c) => FailoverClass::Failover(ErrKind::Http5xx),
57 _ => FailoverClass::Fatal, },
59 IntelError::Parse(_) => FailoverClass::Fatal,
61 IntelError::Unsupported(_) => FailoverClass::Fatal,
63 IntelError::AllEndpointsDown(_) => FailoverClass::Fatal,
65 }
66}
67
68pub fn is_auth(err: &IntelError) -> bool {
72 matches!(err, IntelError::Http(401 | 403, _))
73}
74
75pub fn is_transient_status(code: u16) -> bool {
80 code == 429 || (500..600).contains(&code)
81}
82
83pub struct SweepResult {
87 pub outcome: Result<Response, IntelError>,
88 pub failover: Option<(usize, usize)>,
90 pub breaker_changes: Vec<(usize, BreakerTransition)>,
92 pub active_change: Option<usize>,
94 pub served_by: Option<usize>,
96}
97
98pub fn complete_resilient(
101 list: &mut EndpointList,
102 req: &Request,
103 timeout: Duration,
104 trace_id: Option<&str>,
105) -> SweepResult {
106 let order = list.attempt_order();
107 let cfg = *list.breaker_config();
108 let mut breaker_changes = Vec::new();
109 let mut failover = None;
110 let mut last_err: Option<IntelError> = None;
111 let mut prev_idx: Option<usize> = None;
112
113 if order.is_empty() {
116 return SweepResult {
117 outcome: Err(IntelError::AllEndpointsDown(None)),
118 failover: None,
119 breaker_changes,
120 active_change: None,
121 served_by: None,
122 };
123 }
124
125 for idx in order {
126 if let Some(prev) = prev_idx
128 && prev != idx
129 {
130 failover = Some((prev, idx));
131 }
132 prev_idx = Some(idx);
133
134 match list.ep(idx).complete_once(req, timeout, trace_id) {
135 Ok((resp, latency)) => {
136 if let Some(t) = list.ep(idx).health.record_success(latency) {
137 breaker_changes.push((idx, t));
138 }
139 let mut active_change = list.set_active(idx);
140 if let Some(snapped) = list.prefer_lowest_healthy() {
142 active_change = Some(snapped);
143 }
144 return SweepResult {
145 outcome: Ok(resp),
146 failover,
147 breaker_changes,
148 active_change,
149 served_by: Some(idx),
150 };
151 }
152 Err(e) => match classify(&e) {
153 FailoverClass::Failover(kind) => {
154 if let Some(t) = list.ep(idx).health.record_failure(kind, &cfg) {
155 breaker_changes.push((idx, t));
156 }
157 last_err = Some(e);
158 continue; }
160 FailoverClass::Fatal => {
161 return SweepResult {
163 outcome: Err(e),
164 failover,
165 breaker_changes,
166 active_change: None,
167 served_by: None,
168 };
169 }
170 },
171 }
172 }
173
174 SweepResult {
176 outcome: Err(IntelError::AllEndpointsDown(last_err.map(Box::new))),
177 failover,
178 breaker_changes,
179 active_change: None,
180 served_by: None,
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::io;
188
189 fn io_err(kind: io::ErrorKind) -> IntelError {
190 IntelError::Transport(io::Error::new(kind, "x"))
191 }
192
193 #[test]
194 fn transport_errors_are_failover_class() {
195 assert!(matches!(
196 classify(&io_err(io::ErrorKind::ConnectionRefused)),
197 FailoverClass::Failover(ErrKind::Refused)
198 ));
199 assert!(matches!(
200 classify(&io_err(io::ErrorKind::TimedOut)),
201 FailoverClass::Failover(ErrKind::Timeout)
202 ));
203 assert!(matches!(
204 classify(&io_err(io::ErrorKind::ConnectionReset)),
205 FailoverClass::Failover(ErrKind::Reset)
206 ));
207 }
208
209 #[test]
210 fn http_5xx_and_429_failover_but_4xx_does_not() {
211 assert!(matches!(
212 classify(&IntelError::Http(503, "x".into())),
213 FailoverClass::Failover(ErrKind::Http5xx)
214 ));
215 assert!(matches!(
216 classify(&IntelError::Http(429, "x".into())),
217 FailoverClass::Failover(ErrKind::Http429)
218 ));
219 assert_eq!(
221 classify(&IntelError::Http(401, "x".into())),
222 FailoverClass::Fatal
223 );
224 assert_eq!(
225 classify(&IntelError::Http(403, "x".into())),
226 FailoverClass::Fatal
227 );
228 assert_eq!(
229 classify(&IntelError::Http(400, "x".into())),
230 FailoverClass::Fatal
231 );
232 assert_eq!(
233 classify(&IntelError::Http(404, "x".into())),
234 FailoverClass::Fatal
235 );
236 }
237
238 #[test]
239 fn malformed_body_is_fatal_not_failover() {
240 assert_eq!(
241 classify(&IntelError::Parse("bad json".into())),
242 FailoverClass::Fatal
243 );
244 }
245
246 #[test]
247 fn auth_detection_distinguishes_from_all_down() {
248 assert!(is_auth(&IntelError::Http(401, "x".into())));
249 assert!(is_auth(&IntelError::Http(403, "x".into())));
250 assert!(!is_auth(&IntelError::Http(503, "x".into())));
251 assert!(!is_auth(&io_err(io::ErrorKind::ConnectionRefused)));
252 }
253
254 #[test]
255 fn transient_status_matches_the_failover_class_split() {
256 for c in [429, 500, 502, 503, 504, 599] {
259 assert!(is_transient_status(c), "{c} should be transient");
260 }
261 for c in [200, 400, 401, 403, 404, 418] {
262 assert!(!is_transient_status(c), "{c} should NOT be transient");
263 }
264 }
265
266 use std::io::{Read, Write};
272 use std::net::TcpListener;
273
274 fn serve_status(status: u16) -> String {
277 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
278 let port = listener.local_addr().unwrap().port();
279 std::thread::spawn(move || {
280 if let Ok((mut s, _)) = listener.accept() {
281 let mut buf = [0u8; 2048];
282 let _ = s.read(&mut buf); let body = if status == 200 {
284 r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#
285 } else {
286 r#"{"error":{"message":"boom"}}"#
287 };
288 let resp = format!(
289 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
290 body.len()
291 );
292 let _ = s.write_all(resp.as_bytes());
293 let _ = s.flush();
294 }
295 });
296 format!("http://127.0.0.1:{port}")
297 }
298
299 fn serve_sequence(statuses: Vec<u16>) -> String {
303 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
304 let port = listener.local_addr().unwrap().port();
305 std::thread::spawn(move || {
306 for status in statuses {
307 let Ok((mut s, _)) = listener.accept() else {
308 break;
309 };
310 let mut buf = [0u8; 2048];
311 let _ = s.read(&mut buf);
312 let body = if status == 200 {
313 r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#
314 } else {
315 r#"{"error":{"message":"boom"}}"#
316 };
317 let resp = format!(
318 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
319 body.len()
320 );
321 let _ = s.write_all(resp.as_bytes());
322 let _ = s.flush();
323 }
324 });
325 format!("http://127.0.0.1:{port}")
326 }
327
328 fn dead_endpoint() -> String {
331 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
332 let port = listener.local_addr().unwrap().port();
333 drop(listener);
334 format!("http://127.0.0.1:{port}")
335 }
336
337 fn req() -> Request {
338 Request {
339 model: "m".into(),
340 messages: vec![crate::wire::intel::Message::user("hi")],
341 tools: Vec::new(),
342 max_tokens: 16,
343 temperature: Some(0.0),
344 }
345 }
346
347 fn list_of(uris: &[String]) -> EndpointList {
348 EndpointList::parse_with_env(&uris.join(","), None, &|_| None).unwrap()
349 }
350
351 #[test]
352 fn connect_failure_advances_to_next_healthy_endpoint() {
353 let good = serve_status(200);
354 let mut list = list_of(&[dead_endpoint(), good]);
355 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
356 assert!(r.outcome.is_ok(), "sweep failed over to the healthy ep");
357 assert_eq!(r.served_by, Some(1));
358 assert_eq!(r.failover, Some((0, 1)));
360 }
361
362 #[test]
363 fn http_5xx_advances_to_next_endpoint() {
364 let bad = serve_status(503);
365 let good = serve_status(200);
366 let mut list = list_of(&[bad, good]);
367 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
368 assert!(r.outcome.is_ok());
369 assert_eq!(r.served_by, Some(1));
370 }
371
372 #[test]
373 fn http_4xx_does_not_failover() {
374 let bad = serve_status(400);
375 let good = serve_status(200);
376 let mut list = list_of(&[bad, good]);
377 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
378 assert!(matches!(r.outcome, Err(IntelError::Http(400, _))));
380 assert_eq!(r.served_by, None);
381 assert_eq!(r.failover, None);
382 }
383
384 #[test]
385 fn auth_401_does_not_failover() {
386 let bad = serve_status(401);
387 let good = serve_status(200);
388 let mut list = list_of(&[bad, good]);
389 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
390 assert!(matches!(r.outcome, Err(IntelError::Http(401, _))));
391 assert!(is_auth(&r.outcome.unwrap_err()));
392 }
393
394 #[test]
395 fn circuit_broken_endpoint_is_skipped() {
396 let good = serve_status(200);
397 let mut list = list_of(&[dead_endpoint(), good]);
398 let cfg = *list.breaker_config();
399 for _ in 0..3 {
401 list.ep(0).health.record_failure(ErrKind::Refused, &cfg);
402 }
403 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
406 assert!(r.outcome.is_ok());
407 assert_eq!(r.served_by, Some(1));
408 assert_eq!(r.failover, None, "broken ep was skipped, not failed-over");
409 }
410
411 #[test]
412 fn all_endpoints_down_yields_all_endpoints_down_error() {
413 let mut list = list_of(&[dead_endpoint(), dead_endpoint()]);
414 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
417 assert!(matches!(r.outcome, Err(IntelError::AllEndpointsDown(_))));
418 for _ in 0..3 {
421 let _ = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
422 }
423 assert!(list.all_down());
424 assert!(list.attempt_order().is_empty());
425 }
426
427 #[test]
428 fn transient_5xx_is_retried_on_the_same_endpoint() {
429 let ep = serve_sequence(vec![503, 200]);
434 let mut list = list_of(&[ep]);
435 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
436 assert!(r.outcome.is_ok(), "same-endpoint retry cleared the 503");
437 assert_eq!(r.served_by, Some(0));
438 assert_eq!(r.failover, None, "handled in place, not failed over");
439 }
440
441 #[test]
442 fn transient_429_is_retried_then_succeeds() {
443 let ep = serve_sequence(vec![429, 200]);
444 let mut list = list_of(&[ep]);
445 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
446 assert!(r.outcome.is_ok(), "429 rate-limit blip was retried");
447 assert_eq!(r.served_by, Some(0));
448 }
449
450 #[test]
451 fn non_transient_4xx_is_not_retried() {
452 let ep = serve_sequence(vec![400, 200]);
456 let mut list = list_of(&[ep]);
457 let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
458 assert!(
459 matches!(r.outcome, Err(IntelError::Http(400, _))),
460 "4xx must surface on the first dial, not be retried"
461 );
462 }
463}