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