Skip to main content

agent_sdk_providers/
fallback.rs

1//! Provider failover: try an ordered list of providers until one succeeds.
2//!
3//! [`FallbackProvider`] wraps a primary [`LlmProvider`] plus an ordered list of
4//! secondaries. On a *retryable* failure — a [`ChatOutcome::RateLimited`] /
5//! [`ChatOutcome::ServerError`], or a transport-level error (timeout, dropped
6//! connection) — it advances to the next provider. Non-retryable outcomes
7//! ([`ChatOutcome::InvalidRequest`] and a successful response) short-circuit and
8//! are returned as-is, since retrying them on a different backend would not
9//! help.
10//!
11//! `FallbackProvider` itself implements [`LlmProvider`], so it composes with the
12//! rest of the stack (`run_structured`, `RefreshingProvider`, `ModelRouter`,
13//! the agent-sdk facade) anywhere a `&dyn LlmProvider` is expected.
14
15use std::sync::Arc;
16
17use agent_sdk_foundation::llm::{ChatOutcome, ChatRequest};
18use anyhow::Result;
19use async_trait::async_trait;
20use futures::StreamExt;
21
22use crate::provider::LlmProvider;
23use crate::streaming::{StreamBox, StreamDelta};
24
25/// An [`LlmProvider`] that fails over across an ordered list of backends.
26///
27/// Construct with a primary, then layer secondaries with
28/// [`with_fallback`](Self::with_fallback) (or build the whole chain at once with
29/// [`from_providers`](Self::from_providers)).
30pub struct FallbackProvider {
31    primary: Arc<dyn LlmProvider>,
32    fallbacks: Vec<Arc<dyn LlmProvider>>,
33}
34
35impl FallbackProvider {
36    /// Create a fallback chain with a single primary provider (no secondaries
37    /// yet).
38    #[must_use]
39    pub fn new(primary: Arc<dyn LlmProvider>) -> Self {
40        Self {
41            primary,
42            fallbacks: Vec::new(),
43        }
44    }
45
46    /// Append a secondary provider to the end of the failover order.
47    #[must_use]
48    pub fn with_fallback(mut self, provider: Arc<dyn LlmProvider>) -> Self {
49        self.fallbacks.push(provider);
50        self
51    }
52
53    /// Build a chain from a primary and an ordered iterator of secondaries.
54    #[must_use]
55    pub fn from_providers(
56        primary: Arc<dyn LlmProvider>,
57        fallbacks: impl IntoIterator<Item = Arc<dyn LlmProvider>>,
58    ) -> Self {
59        Self {
60            primary,
61            fallbacks: fallbacks.into_iter().collect(),
62        }
63    }
64
65    /// Total number of providers in the chain (primary + secondaries).
66    #[must_use]
67    pub fn len(&self) -> usize {
68        1 + self.fallbacks.len()
69    }
70
71    /// Always `false` — a chain always has at least the primary.
72    #[must_use]
73    pub const fn is_empty(&self) -> bool {
74        false
75    }
76
77    /// The providers in failover order (primary first), cloned for ownership.
78    fn ordered(&self) -> Vec<Arc<dyn LlmProvider>> {
79        let mut providers = Vec::with_capacity(self.len());
80        providers.push(Arc::clone(&self.primary));
81        providers.extend(self.fallbacks.iter().map(Arc::clone));
82        providers
83    }
84}
85
86/// A `chat` result is worth retrying on the next provider when the provider was
87/// rate-limited or server-errored, or the call failed at the transport layer.
88const fn is_retryable(result: &Result<ChatOutcome>) -> bool {
89    matches!(
90        result,
91        Err(_) | Ok(ChatOutcome::RateLimited(_) | ChatOutcome::ServerError(_))
92    )
93}
94
95#[async_trait]
96impl LlmProvider for FallbackProvider {
97    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
98        let providers = self.ordered();
99        let last = providers.len() - 1;
100        for (idx, provider) in providers.iter().enumerate() {
101            let result = provider.chat(request.clone()).await;
102            if idx == last || !is_retryable(&result) {
103                return result;
104            }
105            log::warn!(
106                "FallbackProvider: provider '{}' failed retryably, failing over to next",
107                provider.provider()
108            );
109        }
110        // `ordered()` is never empty (the primary is always present), so the
111        // loop above always returns. This keeps the signature total without an
112        // `unwrap`.
113        Ok(ChatOutcome::ServerError(
114            "FallbackProvider: no providers configured".to_owned(),
115        ))
116    }
117
118    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
119        let providers = self.ordered();
120        Box::pin(async_stream::stream! {
121            let last = providers.len() - 1;
122            for (idx, provider) in providers.iter().enumerate() {
123                let is_last = idx == last;
124                let mut stream = provider.chat_stream(request.clone());
125                // Whether this provider has emitted any non-error delta. Once it
126                // has, we are committed to it: failing over mid-stream would
127                // double-emit content, so a later error is surfaced as-is.
128                let mut committed = false;
129                let mut failed_over = false;
130
131                while let Some(item) = stream.next().await {
132                    match item {
133                        Ok(StreamDelta::Error { message, kind }) => {
134                            if !committed && !is_last && kind.is_recoverable() {
135                                log::warn!(
136                                    "FallbackProvider: provider '{}' recoverable stream error ({kind:?}), failing over",
137                                    provider.provider()
138                                );
139                                failed_over = true;
140                                break;
141                            }
142                            yield Ok(StreamDelta::Error { message, kind });
143                        }
144                        Ok(delta) => {
145                            committed = true;
146                            yield Ok(delta);
147                        }
148                        Err(error) => {
149                            if !committed && !is_last {
150                                log::warn!(
151                                    "FallbackProvider: provider '{}' stream transport error, failing over: {error}",
152                                    provider.provider()
153                                );
154                                failed_over = true;
155                                break;
156                            }
157                            yield Err(error);
158                        }
159                    }
160                }
161
162                if !failed_over {
163                    return;
164                }
165            }
166        })
167    }
168
169    /// Delegate live model discovery to the primary provider so wrapping in a
170    /// fallback never silently loses `list_models`.
171    async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
172        self.primary.list_models().await
173    }
174
175    fn model(&self) -> &str {
176        self.primary.model()
177    }
178
179    fn provider(&self) -> &'static str {
180        self.primary.provider()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    use std::sync::Mutex;
189    use std::sync::atomic::{AtomicUsize, Ordering};
190
191    use agent_sdk_foundation::llm::{ChatResponse, ContentBlock, StopReason, Usage};
192    use anyhow::anyhow;
193
194    use crate::streaming::StreamErrorKind;
195
196    /// A provider that replays a queue of `chat` results and counts its calls.
197    struct ScriptedProvider {
198        name: &'static str,
199        results: Mutex<std::collections::VecDeque<Result<ChatOutcome>>>,
200        stream_deltas: Mutex<Vec<Result<StreamDelta>>>,
201        calls: AtomicUsize,
202    }
203
204    impl ScriptedProvider {
205        fn chat_only(name: &'static str, results: Vec<Result<ChatOutcome>>) -> Arc<Self> {
206            Arc::new(Self {
207                name,
208                results: Mutex::new(results.into()),
209                stream_deltas: Mutex::new(Vec::new()),
210                calls: AtomicUsize::new(0),
211            })
212        }
213
214        fn streaming(name: &'static str, deltas: Vec<Result<StreamDelta>>) -> Arc<Self> {
215            Arc::new(Self {
216                name,
217                results: Mutex::new(std::collections::VecDeque::new()),
218                stream_deltas: Mutex::new(deltas),
219                calls: AtomicUsize::new(0),
220            })
221        }
222
223        fn calls(&self) -> usize {
224            self.calls.load(Ordering::SeqCst)
225        }
226    }
227
228    #[async_trait]
229    impl LlmProvider for ScriptedProvider {
230        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
231            self.calls.fetch_add(1, Ordering::SeqCst);
232            self.results
233                .lock()
234                .map_err(|_| anyhow!("results lock poisoned"))?
235                .pop_front()
236                .unwrap_or_else(|| Ok(ChatOutcome::ServerError("exhausted".to_owned())))
237        }
238
239        async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
240            Ok(vec![crate::provider::ModelInfo {
241                id: format!("{}-model", self.name),
242                display_name: None,
243                context_window: None,
244                max_output_tokens: None,
245            }])
246        }
247
248        fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
249            self.calls.fetch_add(1, Ordering::SeqCst);
250            let deltas: Vec<Result<StreamDelta>> = self
251                .stream_deltas
252                .lock()
253                .map(|d| {
254                    d.iter()
255                        .map(|r| match r {
256                            Ok(delta) => Ok(delta.clone()),
257                            Err(e) => Err(anyhow!("{e}")),
258                        })
259                        .collect()
260                })
261                .unwrap_or_default();
262            Box::pin(async_stream::stream! {
263                for delta in deltas {
264                    yield delta;
265                }
266            })
267        }
268
269        fn model(&self) -> &str {
270            self.name
271        }
272
273        fn provider(&self) -> &'static str {
274            self.name
275        }
276    }
277
278    fn success(text: &str) -> ChatOutcome {
279        ChatOutcome::Success(ChatResponse {
280            id: "r".to_owned(),
281            content: vec![ContentBlock::Text {
282                text: text.to_owned(),
283            }],
284            model: "m".to_owned(),
285            stop_reason: Some(StopReason::EndTurn),
286            usage: Usage {
287                input_tokens: 1,
288                output_tokens: 1,
289                cached_input_tokens: 0,
290                cache_creation_input_tokens: 0,
291            },
292        })
293    }
294
295    fn request() -> ChatRequest {
296        ChatRequest::new("sys", vec![agent_sdk_foundation::llm::Message::user("hi")])
297    }
298
299    #[tokio::test]
300    async fn server_error_fails_over_to_secondary() -> Result<()> {
301        let primary = ScriptedProvider::chat_only(
302            "primary",
303            vec![Ok(ChatOutcome::ServerError("boom".to_owned()))],
304        );
305        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
306        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
307
308        let outcome = fb.chat(request()).await?;
309        assert!(matches!(outcome, ChatOutcome::Success(r) if r.first_text() == Some("ok")));
310        assert_eq!(primary.calls(), 1);
311        assert_eq!(secondary.calls(), 1);
312        Ok(())
313    }
314
315    #[tokio::test]
316    async fn rate_limit_fails_over() -> Result<()> {
317        let primary =
318            ScriptedProvider::chat_only("primary", vec![Ok(ChatOutcome::RateLimited(None))]);
319        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
320        let fb = FallbackProvider::from_providers(primary.clone(), [secondary.clone() as Arc<_>]);
321
322        let outcome = fb.chat(request()).await?;
323        assert!(matches!(outcome, ChatOutcome::Success(_)));
324        assert_eq!(secondary.calls(), 1);
325        Ok(())
326    }
327
328    #[tokio::test]
329    async fn transport_error_fails_over() -> Result<()> {
330        let primary = ScriptedProvider::chat_only("primary", vec![Err(anyhow!("timeout"))]);
331        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
332        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
333
334        let outcome = fb.chat(request()).await?;
335        assert!(matches!(outcome, ChatOutcome::Success(_)));
336        Ok(())
337    }
338
339    #[tokio::test]
340    async fn invalid_request_does_not_fail_over() -> Result<()> {
341        let primary = ScriptedProvider::chat_only(
342            "primary",
343            vec![Ok(ChatOutcome::InvalidRequest("bad".to_owned()))],
344        );
345        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
346        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
347
348        let outcome = fb.chat(request()).await?;
349        assert!(matches!(outcome, ChatOutcome::InvalidRequest(_)));
350        // The non-retryable outcome short-circuits: the secondary is untouched.
351        assert_eq!(secondary.calls(), 0);
352        Ok(())
353    }
354
355    #[tokio::test]
356    async fn last_provider_outcome_is_returned_when_all_fail() -> Result<()> {
357        let primary = ScriptedProvider::chat_only(
358            "primary",
359            vec![Ok(ChatOutcome::ServerError("a".to_owned()))],
360        );
361        let secondary = ScriptedProvider::chat_only(
362            "secondary",
363            vec![Ok(ChatOutcome::ServerError("b".to_owned()))],
364        );
365        let fb = FallbackProvider::new(primary).with_fallback(secondary);
366
367        let outcome = fb.chat(request()).await?;
368        assert!(matches!(outcome, ChatOutcome::ServerError(msg) if msg == "b"));
369        Ok(())
370    }
371
372    #[tokio::test]
373    async fn list_models_delegates_to_primary() -> Result<()> {
374        let primary = ScriptedProvider::chat_only("primary", vec![]);
375        let secondary = ScriptedProvider::chat_only("secondary", vec![]);
376        let fb = FallbackProvider::new(primary).with_fallback(secondary);
377
378        let models = fb.list_models().await?;
379        // Discovery is served by the primary, not the default "unsupported".
380        assert_eq!(models.len(), 1);
381        assert_eq!(models[0].id, "primary-model");
382        Ok(())
383    }
384
385    #[tokio::test]
386    async fn stream_fails_over_on_recoverable_first_error() -> Result<()> {
387        let primary = ScriptedProvider::streaming(
388            "primary",
389            vec![Ok(StreamDelta::Error {
390                message: "rate limited".to_owned(),
391                kind: StreamErrorKind::RateLimited,
392            })],
393        );
394        let secondary = ScriptedProvider::streaming(
395            "secondary",
396            vec![
397                Ok(StreamDelta::TextDelta {
398                    delta: "hello".to_owned(),
399                    block_index: 0,
400                }),
401                Ok(StreamDelta::Done {
402                    stop_reason: Some(StopReason::EndTurn),
403                }),
404            ],
405        );
406        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
407
408        let mut stream = fb.chat_stream(request());
409        let mut text = String::new();
410        while let Some(item) = stream.next().await {
411            if let StreamDelta::TextDelta { delta, .. } = item? {
412                text.push_str(&delta);
413            }
414        }
415        assert_eq!(text, "hello");
416        assert_eq!(primary.calls(), 1);
417        assert_eq!(secondary.calls(), 1);
418        Ok(())
419    }
420
421    #[test]
422    fn reports_primary_identity() {
423        let primary = ScriptedProvider::chat_only("primary", vec![]);
424        let fb = FallbackProvider::new(primary);
425        assert_eq!(fb.provider(), "primary");
426        assert_eq!(fb.model(), "primary");
427        assert_eq!(fb.len(), 1);
428        assert!(!fb.is_empty());
429    }
430}