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    fn name(&self) -> &str {
128        &self.name
129    }
130
131    /// The primary's, for the single-endpoint callers that predate fallback.
132    /// [`endpoints`](Provider::endpoints) is what authorization uses.
133    fn endpoint(&self) -> Option<&str> {
134        self.primary.endpoint()
135    }
136
137    /// BOTH providers' endpoints.
138    ///
139    /// This is the reason `endpoints` exists at all. The 0.8.0 egress policy is
140    /// deny-by-default and a run authorizes its provider's host before its first
141    /// step; a combinator that reported only the primary's host would leave the
142    /// secondary's completely ungoverned, so a fallback would be a way to reach a
143    /// host the policy never saw.
144    fn endpoints(&self) -> Vec<&str> {
145        let mut out = self.primary.endpoints();
146        out.extend(self.secondary.endpoints());
147        out
148    }
149
150    /// The LEAF that answered, not the branch it sits in.
151    ///
152    /// A nested `Fallback::new(a, Fallback::new(b, c))` whose `c` answered must
153    /// record `"c"`, not `"b -> c"`: the row exists because one label for a whole
154    /// run stops being true the moment a run can use two, and naming a sub-chain
155    /// reintroduces exactly that ambiguity one level down.
156    fn last_served(&self) -> Option<String> {
157        match self.served.load(Ordering::Relaxed) {
158            1 => Some(
159                self.primary
160                    .last_served()
161                    .unwrap_or_else(|| self.primary.name().to_string()),
162            ),
163            2 => Some(
164                self.secondary
165                    .last_served()
166                    .unwrap_or_else(|| self.secondary.name().to_string()),
167            ),
168            _ => None,
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::error::ProviderErrorKind;
177
178    struct Fixed {
179        label: &'static str,
180        result: fn() -> Result<CompletionResponse>,
181        endpoint: Option<&'static str>,
182    }
183
184    impl Provider for Fixed {
185        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse> {
186            (self.result)()
187        }
188        fn name(&self) -> &str {
189            self.label
190        }
191        fn endpoint(&self) -> Option<&str> {
192            self.endpoint
193        }
194    }
195
196    fn ok() -> Result<CompletionResponse> {
197        Ok(CompletionResponse {
198            text: Some("hello".into()),
199            ..Default::default()
200        })
201    }
202    fn down() -> Result<CompletionResponse> {
203        Err(Error::provider_status(503, None, "down"))
204    }
205    fn bad_key() -> Result<CompletionResponse> {
206        Err(Error::provider_status(401, None, "bad key"))
207    }
208    fn boom() -> Result<CompletionResponse> {
209        panic!("the secondary must not be called");
210    }
211
212    #[allow(clippy::needless_update)] // `media` is cfg'd out in the default build
213    fn req() -> CompletionRequest {
214        CompletionRequest {
215            system: String::new(),
216            user: "hi".into(),
217            tools: Vec::new(),
218            ..Default::default()
219        }
220    }
221
222    fn p(label: &'static str, result: fn() -> Result<CompletionResponse>) -> Fixed {
223        Fixed {
224            label,
225            result,
226            endpoint: None,
227        }
228    }
229
230    #[tokio::test]
231    async fn a_down_primary_is_answered_by_the_secondary() {
232        let f = Fallback::new(p("first", down), p("second", ok));
233        let out = f.complete(req()).await.unwrap();
234        assert_eq!(out.text.as_deref(), Some("hello"));
235        assert_eq!(f.last_served().as_deref(), Some("second"));
236        assert_eq!(f.name(), "first -> second");
237    }
238
239    #[tokio::test]
240    async fn a_working_primary_is_never_backed_up() {
241        let f = Fallback::new(p("first", ok), p("second", boom));
242        assert!(f.complete(req()).await.is_ok());
243        assert_eq!(f.last_served().as_deref(), Some("first"));
244    }
245
246    #[tokio::test]
247    async fn a_failure_the_secondary_would_share_does_not_fall_over() {
248        // A wrong key is not more valid at a different vendor, and an unacceptable
249        // request is unacceptable twice. `boom` panics if this is got wrong.
250        let f = Fallback::new(p("first", bad_key), p("second", boom));
251        let err = f.complete(req()).await.unwrap_err();
252        let Error::Provider { kind, status, .. } = err else {
253            panic!("expected a provider error, got {err:?}");
254        };
255        assert_eq!(kind, ProviderErrorKind::Auth);
256        assert_eq!(status, Some(401));
257        // Nothing served it, so nothing is claimed to have.
258        assert_eq!(f.last_served(), None);
259    }
260
261    #[tokio::test]
262    async fn both_failing_reports_the_secondarys_error() {
263        let f = Fallback::new(p("first", down), p("second", bad_key));
264        let err = f.complete(req()).await.unwrap_err();
265        let Error::Provider { kind, .. } = err else {
266            panic!("expected a provider error");
267        };
268        // The last thing tried is the thing that failed, which is what a caller
269        // needs to act on — the primary's failure is in the trace and the log.
270        assert_eq!(kind, ProviderErrorKind::Auth);
271    }
272
273    #[tokio::test]
274    async fn three_providers_nest_and_the_leaf_is_what_gets_recorded() {
275        let f = Fallback::new(p("a", down), Fallback::new(p("b", down), p("c", ok)));
276        assert!(f.complete(req()).await.is_ok());
277        assert_eq!(f.name(), "a -> b -> c");
278        // Not "b -> c": the row has to name the provider, not the branch.
279        assert_eq!(f.last_served().as_deref(), Some("c"));
280    }
281
282    #[test]
283    fn authorization_sees_every_endpoint_in_the_chain() {
284        // The security property: a combinator reporting only the primary's host
285        // would let a fallback reach a host the deny-by-default egress policy never
286        // checked.
287        let a = Fixed {
288            label: "a",
289            result: ok,
290            endpoint: Some("https://a.example/v1"),
291        };
292        let b = Fixed {
293            label: "b",
294            result: ok,
295            endpoint: Some("https://b.example/v1"),
296        };
297        let f = Fallback::new(a, b);
298        assert_eq!(
299            f.endpoints(),
300            vec!["https://a.example/v1", "https://b.example/v1"]
301        );
302        // A provider with no endpoint contributes none, rather than a blank.
303        let g = Fallback::new(p("x", ok), p("y", ok));
304        assert!(g.endpoints().is_empty());
305    }
306}