Skip to main content

io_harness/provider/
fallback.rs

1//! One provider standing behind another.
2//!
3//! Three providers are implemented and, before 0.11, only ever one was reachable
4//! in a run: every entry point is generic over a single `P: Provider`, and
5//! [`Provider::complete`] returns `impl Future`, so there is no `dyn Provider` to
6//! hold a list behind.
7//!
8//! [`Fallback`] is how fallback happens without changing that. It is itself a
9//! `Provider`, so `run(&contract, &Fallback::new(a, b), &store)` needs no new
10//! entry point, and it nests — `Fallback::new(a, Fallback::new(b, c))` — for three.
11//!
12//! It falls through on a failure another provider might not have and refuses to on
13//! one it would meet too. Retrying a request the server read and rejected is two
14//! failures instead of one, and a wrong API key is not more valid at a different
15//! vendor.
16//!
17//! ## What it does not promise
18//!
19//! That the two providers are equivalent. Falling over swaps the model mid-run, and
20//! the harness cannot see whether the replacement is as capable, as well tuned, or
21//! even the same size. An operator configuring a fallback should expect that a run
22//! which fell over may behave differently from one that did not — which is why the
23//! provider that actually answered is recorded per step rather than inferred from
24//! the run's configuration.
25
26use std::sync::atomic::{AtomicU8, Ordering};
27
28use crate::error::{Error, Result};
29use crate::provider::{CompletionRequest, CompletionResponse, Provider};
30
31/// Try `primary`; on a failure another provider might survive, try `secondary`.
32///
33/// ```no_run
34/// use io_harness::provider::Fallback;
35/// use io_harness::{Anthropic, OpenRouter};
36///
37/// # fn main() -> io_harness::Result<()> {
38/// let provider = Fallback::new(OpenRouter::from_env()?, Anthropic::from_env()?);
39/// # Ok(())
40/// # }
41/// ```
42#[derive(Debug)]
43pub struct Fallback<A, B> {
44    primary: A,
45    secondary: B,
46    /// `name()` returns `&str`, so the combined label has to be owned somewhere.
47    name: String,
48    /// Which one answered last: 0 nobody, 1 primary, 2 secondary. An atomic rather
49    /// than a lock because a `MutexGuard` is not `Send` and `complete`'s future has
50    /// to be. Read by the run loop to record, per step, the provider that actually
51    /// served it — `runs.provider` is one label for a whole run and stops being true
52    /// the moment a run can use two.
53    served: AtomicU8,
54}
55
56impl<A: Provider, B: Provider> Fallback<A, B> {
57    /// `secondary` answers when `primary` fails in a way it might survive.
58    pub fn new(primary: A, secondary: B) -> Self {
59        let name = format!("{} -> {}", primary.name(), secondary.name());
60        Self {
61            primary,
62            secondary,
63            name,
64            served: AtomicU8::new(0),
65        }
66    }
67
68    fn note(&self, who: u8) {
69        self.served.store(who, Ordering::Relaxed);
70    }
71}
72
73/// Whether a different provider is worth trying.
74///
75/// The same question [`ProviderErrorKind::is_retryable`](crate::error::ProviderErrorKind::is_retryable)
76/// answers for a retry, and the same answer: a failure that was about the request or
77/// about the caller's own configuration will happen again at the next vendor, so
78/// falling over just fails twice and takes twice as long doing it.
79fn worth_another_provider(e: &Error) -> bool {
80    matches!(e, Error::Provider { kind, .. } if kind.is_retryable())
81}
82
83// `Sync` on both halves is what makes `complete`'s future `Send`: the combinator
84// holds `&self` across the primary's await, so `&Fallback<A, B>` has to be `Send`,
85// which needs its fields `Sync`. Every real provider is (they hold a `reqwest::Client`
86// and two `String`s), so this costs nothing a caller would notice.
87impl<A: Provider + Sync, B: Provider + Sync> Provider for Fallback<A, B> {
88    /// Both, or neither. Reporting the primary's answer would let an image reach
89    /// a secondary that cannot read it on the one call that matters — the
90    /// fallover — and a fallover already means something has gone wrong. The
91    /// secondary would refuse it (`ensure_media_accepted` runs inside every
92    /// built-in provider too), so the only thing the conjunction changes is
93    /// *when* the caller finds out: before the run, rather than mid-fallover.
94    #[cfg(feature = "media")]
95    fn accepts_images(&self) -> bool {
96        self.primary.accepts_images() && self.secondary.accepts_images()
97    }
98
99    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
100        match self.primary.complete(request.clone()).await {
101            Ok(response) => {
102                self.note(1);
103                Ok(response)
104            }
105            Err(e) if worth_another_provider(&e) => {
106                tracing::warn!(
107                    primary = self.primary.name(),
108                    secondary = self.secondary.name(),
109                    error = %e,
110                    "provider failed; falling over"
111                );
112                let out = self.secondary.complete(request).await;
113                self.note(if out.is_ok() { 2 } else { 0 });
114                out
115            }
116            Err(e) => {
117                // Not worth another provider. The caller gets the primary's error,
118                // unchanged, so the kind they branch on is the real one. Nobody
119                // served this call, and nothing must be able to read that anybody
120                // did — a stale marker is worse than none.
121                self.note(0);
122                Err(e)
123            }
124        }
125    }
126
127    /// Streams from whichever provider serves, as [`complete`](Provider::complete)
128    /// answers from whichever provider serves.
129    ///
130    /// Forwarded rather than left to the default, which would delegate to
131    /// `complete` and collapse a real stream into one delta on the one path a
132    /// caller is most likely to be watching. A fallover mid-stream means the
133    /// primary's deltas were already emitted and the secondary's answer arrives
134    /// whole — the caller sees the primary's partial text followed by the
135    /// secondary's complete text, which is why a delta is provisional until the
136    /// completion returns.
137    async fn complete_streaming(
138        &self,
139        request: CompletionRequest,
140        on_token: &(dyn Fn(&str) + Send + Sync),
141    ) -> Result<CompletionResponse> {
142        match self
143            .primary
144            .complete_streaming(request.clone(), on_token)
145            .await
146        {
147            Ok(response) => {
148                self.note(1);
149                Ok(response)
150            }
151            Err(e) if worth_another_provider(&e) => {
152                tracing::warn!(
153                    primary = self.primary.name(),
154                    secondary = self.secondary.name(),
155                    error = %e,
156                    "provider failed mid-stream; falling over"
157                );
158                let out = self.secondary.complete_streaming(request, on_token).await;
159                self.note(if out.is_ok() { 2 } else { 0 });
160                out
161            }
162            Err(e) => {
163                self.note(0);
164                Err(e)
165            }
166        }
167    }
168
169    fn name(&self) -> &str {
170        &self.name
171    }
172
173    /// The primary's, for the single-endpoint callers that predate fallback.
174    /// [`endpoints`](Provider::endpoints) is what authorization uses.
175    fn endpoint(&self) -> Option<&str> {
176        self.primary.endpoint()
177    }
178
179    /// BOTH providers' endpoints.
180    ///
181    /// This is the reason `endpoints` exists at all. The 0.8.0 egress policy is
182    /// deny-by-default and a run authorizes its provider's host before its first
183    /// step; a combinator that reported only the primary's host would leave the
184    /// secondary's completely ungoverned, so a fallback would be a way to reach a
185    /// host the policy never saw.
186    fn endpoints(&self) -> Vec<&str> {
187        let mut out = self.primary.endpoints();
188        out.extend(self.secondary.endpoints());
189        out
190    }
191
192    /// The LEAF that answered, not the branch it sits in.
193    ///
194    /// A nested `Fallback::new(a, Fallback::new(b, c))` whose `c` answered must
195    /// record `"c"`, not `"b -> c"`: the row exists because one label for a whole
196    /// run stops being true the moment a run can use two, and naming a sub-chain
197    /// reintroduces exactly that ambiguity one level down.
198    fn last_served(&self) -> Option<String> {
199        match self.served.load(Ordering::Relaxed) {
200            1 => Some(
201                self.primary
202                    .last_served()
203                    .unwrap_or_else(|| self.primary.name().to_string()),
204            ),
205            2 => Some(
206                self.secondary
207                    .last_served()
208                    .unwrap_or_else(|| self.secondary.name().to_string()),
209            ),
210            _ => None,
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::error::ProviderErrorKind;
219
220    struct Fixed {
221        label: &'static str,
222        result: fn() -> Result<CompletionResponse>,
223        endpoint: Option<&'static str>,
224    }
225
226    impl Provider for Fixed {
227        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse> {
228            (self.result)()
229        }
230        fn name(&self) -> &str {
231            self.label
232        }
233        fn endpoint(&self) -> Option<&str> {
234            self.endpoint
235        }
236    }
237
238    fn ok() -> Result<CompletionResponse> {
239        Ok(CompletionResponse {
240            text: Some("hello".into()),
241            ..Default::default()
242        })
243    }
244    fn down() -> Result<CompletionResponse> {
245        Err(Error::provider_status(503, None, "down"))
246    }
247    fn bad_key() -> Result<CompletionResponse> {
248        Err(Error::provider_status(401, None, "bad key"))
249    }
250    fn boom() -> Result<CompletionResponse> {
251        panic!("the secondary must not be called");
252    }
253
254    #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
255    fn req() -> CompletionRequest {
256        CompletionRequest {
257            system: String::new(),
258            user: "hi".into(),
259            tools: Vec::new(),
260            ..Default::default()
261        }
262    }
263
264    fn p(label: &'static str, result: fn() -> Result<CompletionResponse>) -> Fixed {
265        Fixed {
266            label,
267            result,
268            endpoint: None,
269        }
270    }
271
272    #[tokio::test]
273    async fn a_down_primary_is_answered_by_the_secondary() {
274        let f = Fallback::new(p("first", down), p("second", ok));
275        let out = f.complete(req()).await.unwrap();
276        assert_eq!(out.text.as_deref(), Some("hello"));
277        assert_eq!(f.last_served().as_deref(), Some("second"));
278        assert_eq!(f.name(), "first -> second");
279    }
280
281    #[tokio::test]
282    async fn a_working_primary_is_never_backed_up() {
283        let f = Fallback::new(p("first", ok), p("second", boom));
284        assert!(f.complete(req()).await.is_ok());
285        assert_eq!(f.last_served().as_deref(), Some("first"));
286    }
287
288    #[tokio::test]
289    async fn a_failure_the_secondary_would_share_does_not_fall_over() {
290        // A wrong key is not more valid at a different vendor, and an unacceptable
291        // request is unacceptable twice. `boom` panics if this is got wrong.
292        let f = Fallback::new(p("first", bad_key), p("second", boom));
293        let err = f.complete(req()).await.unwrap_err();
294        let Error::Provider { kind, status, .. } = err else {
295            panic!("expected a provider error, got {err:?}");
296        };
297        assert_eq!(kind, ProviderErrorKind::Auth);
298        assert_eq!(status, Some(401));
299        // Nothing served it, so nothing is claimed to have.
300        assert_eq!(f.last_served(), None);
301    }
302
303    #[tokio::test]
304    async fn both_failing_reports_the_secondarys_error() {
305        let f = Fallback::new(p("first", down), p("second", bad_key));
306        let err = f.complete(req()).await.unwrap_err();
307        let Error::Provider { kind, .. } = err else {
308            panic!("expected a provider error");
309        };
310        // The last thing tried is the thing that failed, which is what a caller
311        // needs to act on — the primary's failure is in the trace and the log.
312        assert_eq!(kind, ProviderErrorKind::Auth);
313    }
314
315    #[tokio::test]
316    async fn three_providers_nest_and_the_leaf_is_what_gets_recorded() {
317        let f = Fallback::new(p("a", down), Fallback::new(p("b", down), p("c", ok)));
318        assert!(f.complete(req()).await.is_ok());
319        assert_eq!(f.name(), "a -> b -> c");
320        // Not "b -> c": the row has to name the provider, not the branch.
321        assert_eq!(f.last_served().as_deref(), Some("c"));
322    }
323
324    #[test]
325    fn authorization_sees_every_endpoint_in_the_chain() {
326        // The security property: a combinator reporting only the primary's host
327        // would let a fallback reach a host the deny-by-default egress policy never
328        // checked.
329        let a = Fixed {
330            label: "a",
331            result: ok,
332            endpoint: Some("https://a.example/v1"),
333        };
334        let b = Fixed {
335            label: "b",
336            result: ok,
337            endpoint: Some("https://b.example/v1"),
338        };
339        let f = Fallback::new(a, b);
340        assert_eq!(
341            f.endpoints(),
342            vec!["https://a.example/v1", "https://b.example/v1"]
343        );
344        // A provider with no endpoint contributes none, rather than a blank.
345        let g = Fallback::new(p("x", ok), p("y", ok));
346        assert!(g.endpoints().is_empty());
347    }
348}