Skip to main content

agentd/intel/
failover.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The failover decision — sticky-primary with a bounded sweep.
3//!
4//! The client's `complete()` is wrapped: try the **active** endpoint; on a
5//! FAILOVER-CLASS error (connect refused or reset, timeout, HTTP 5xx, 429 that
6//! survived the endpoint's own retry, or a circuit-open skip) advance to the
7//! next *available* endpoint in list order. A *non*-failover error — 401/403
8//! auth, a 4xx request error, a malformed body — returns immediately, because
9//! it would be identical on every endpoint and trying the rest would only burn
10//! the run deadline while hiding the real cause. On success, `active` snaps back
11//! to the lowest-index healthy endpoint, so serving from a fallback is temporary
12//! by construction.
13//!
14//! This module holds the entire selection control flow; the wire, adapter and
15//! JSON path sit below it untouched. Each `complete_once` dials a fresh
16//! connection, which is what makes a re-dial safe to attempt at all. The only
17//! state kept between calls is the cheap per-endpoint health and breaker record.
18
19use 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/// How a single endpoint's outcome is classified for failover.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FailoverClass {
29    /// Try the next endpoint (connect refused/reset, timeout, 5xx, 429).
30    Failover(ErrKind),
31    /// Do not fail over — fatal/observation, identical on every endpoint
32    /// (auth 401/403, 4xx, malformed body).
33    Fatal,
34}
35
36/// Classify an [`IntelError`] for the failover sweep. This decides only whether
37/// to try ANOTHER endpoint; the same-endpoint transient retry has already run
38/// inside `complete_once` before an error reaches here, so anything classified
39/// `Failover` has already survived that retry.
40pub fn classify(err: &IntelError) -> FailoverClass {
41    match err {
42        // Transport-layer failures are always failover-class: the endpoint is
43        // down/moving/wedged — a sibling may be fine.
44        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, // NotFound (DNS), other I/O → treat as down
51            };
52            FailoverClass::Failover(kind)
53        }
54        // HTTP status: 5xx and 429 are failover-class; 401/403 (auth) and other
55        // 4xx are fatal — a bad request/credential is bad on every endpoint.
56        IntelError::Http(code, _) => match *code {
57            500 | 502 | 503 | 504 => FailoverClass::Failover(ErrKind::Http5xx),
58            429 => FailoverClass::Failover(ErrKind::Http429),
59            // any other 5xx is still upstream-transient
60            c if (500..600).contains(&c) => FailoverClass::Failover(ErrKind::Http5xx),
61            _ => FailoverClass::Fatal, // 401/403/4xx
62        },
63        // A malformed body is a bad response everywhere → observation/abort.
64        IntelError::Parse(_) => FailoverClass::Fatal,
65        // An unsupported transport is a config error, not a transient outage.
66        IntelError::Unsupported(_) => FailoverClass::Fatal,
67        // All-endpoints-down is already terminal — not re-classified.
68        IntelError::AllEndpointsDown(_) => FailoverClass::Fatal,
69    }
70}
71
72/// Is this a fatal **auth** failure (401/403)? The all-down backoff needs to
73/// distinguish the two: an auth failure on every endpoint is a misconfiguration
74/// and exits 4 immediately rather than entering the backoff loop. Retrying a
75/// credential error would mask it as a transient outage and leave the operator
76/// with a daemon that looks alive but never works.
77pub fn is_auth(err: &IntelError) -> bool {
78    matches!(err, IntelError::Http(401 | 403, _))
79}
80
81/// HTTP statuses a **same-endpoint** retry may clear: a 429 rate-limit or an
82/// upstream 5xx blip. This set must stay identical to the failover-class HTTP
83/// split in [`classify`], or a status could be retried in place yet refuse to
84/// fail over (or the reverse). A non-transient 4xx — bad request, auth — is a
85/// caller error that is identical on a re-dial and must surface immediately.
86pub fn is_transient_status(code: u16) -> bool {
87    code == 429 || (500..600).contains(&code)
88}
89
90/// The result of one failover sweep, plus the side-channel of breaker and
91/// active-endpoint transitions. The sweep observes these but emits nothing
92/// itself; the caller turns them into metrics, events and the
93/// `agentd://intelligence` body, which keeps this module free of observability
94/// dependencies.
95pub struct SweepResult {
96    pub outcome: Result<Response, IntelError>,
97    /// `(from, to)` if a failover advanced the endpoint within the sweep.
98    pub failover: Option<(usize, usize)>,
99    /// Breaker transitions observed, as `(endpoint_index, transition)`.
100    pub breaker_changes: Vec<(usize, BreakerTransition)>,
101    /// The new active index if it changed (failover or snap-back).
102    pub active_change: Option<usize>,
103    /// The endpoint that ultimately served the request (on success).
104    pub served_by: Option<usize>,
105}
106
107/// Drive one bounded failover sweep for a single logical `complete`. The sweep
108/// visits at most `eps.len()` distinct endpoints and each of them at most once,
109/// so one `complete` can never loop over the list.
110pub 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    // Every breaker is OPEN and still cooling, so there is nothing to dial. The
124    // caller turns this terminal into exit 4 in `once` mode, or into a backoff
125    // and re-arm for a long-lived daemon.
126    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        // A second-or-later attempt within one sweep IS a failover advance, and
138        // is what the caller reports as such.
139        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                // Snap back to the lowest-index healthy endpoint (sticky-primary).
153                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; // advance to the next available endpoint
171                }
172                FailoverClass::Fatal => {
173                    // Auth/4xx/malformed: same on every endpoint → return now.
174                    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    // Every available endpoint failed over, so the whole list is down. The last
187    // failover-class error is carried along as the cause, because the terminal
188    // on its own tells an operator nothing about why.
189    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        // auth / request error → fatal, NOT failover
234        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        // The same-endpoint retry set (429 plus every 5xx) must match
271        // `classify`'s failover-class HTTP codes exactly.
272        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    // --- Sweep integration tests over real TCP endpoints -------------------
281    // A tiny single-shot HTTP server returns a fixed status (+ a canned
282    // OpenAI-compatible body for 200) so the sweep dials a *real* endpoint via
283    // `complete_once`. A closed/never-bound port gives a connect failure.
284
285    use std::io::{Read, Write};
286    use std::net::TcpListener;
287
288    /// Bind `127.0.0.1:0`, serve one request returning `status`, and return the
289    /// `http://127.0.0.1:<port>` URI. The thread self-terminates after one conn.
290    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); // drain the request
297                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    /// Bind `127.0.0.1:0` and serve one response per element of `statuses` on
314    /// successive connections — so a *same-endpoint* retry (which re-dials) walks
315    /// into the next status in the list. Returns the URI.
316    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    /// A bound-then-dropped listener yields a port nothing listens on → connect
343    /// refused (a failover-class transport error).
344    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        // a failover advance was recorded (0 → 1)
373        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        // a 4xx is fatal — the sweep returns it WITHOUT trying endpoint 1.
393        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        // open endpoint 0's breaker up front (threshold 3)
414        for _ in 0..3 {
415            list.ep(0).health.record_failure(ErrKind::Refused, &cfg);
416        }
417        // the sweep skips the broken endpoint 0 entirely → serves on 1, no
418        // failover advance recorded (0 was never dialed).
419        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        // One sweep over two dead endpoints exhausts the list, since each one
429        // failed over, giving the all-down terminal that maps to exit 4 in
430        // `once` mode.
431        let r = complete_resilient(&mut list, &req(), Duration::from_secs(2), None);
432        assert!(matches!(r.outcome, Err(IntelError::AllEndpointsDown(_))));
433        // After enough sweeps the breakers open and `all_down()` (breaker-state)
434        // also reports true — at which point `attempt_order()` is empty.
435        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        // One 503 then a 200 on a SINGLE endpoint: complete_once's same-endpoint
445        // retry rides out the blip and succeeds in place, with no failover and
446        // no exit 4. This matters most in once-mode, which arms no higher-level
447        // retry loop, so without this retry a bare 503 would go straight to
448        // exit 4.
449        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        // 400 then 200 on one endpoint: because a 4xx is NOT retried, the first
469        // (400) response surfaces immediately and the 200 is never consumed. If
470        // the retry wrongly fired on 4xx, this would spuriously succeed.
471        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}