Skip to main content

mentra_provider/
gateway_ring.rs

1//! A ring of interchangeable gateways behind one provider.
2//!
3//! A host that reaches its model through more than one gateway — a remote
4//! relay and a local one, say — wants to prefer one and fall back to the next
5//! when it goes bad, without every layer above learning about the second
6//! address. [`GatewayRing`] is that: a [`Provider`] whose members are
7//! providers, which sends every call to one *current* member and rotates to
8//! the next when the current one keeps failing.
9//!
10//! The runtime above it sees one provider with one descriptor. Its own retry
11//! loop keeps working exactly as before — each retry lands on the ring, the
12//! ring counts it, and once the streak reaches the policy's threshold the
13//! ring finishes *that same call* on the next member. Nothing above the ring
14//! has to know a rotation happened, though a host that wants to can watch
15//! through [`GatewayRing::with_observer`].
16//!
17//! # Members must be interchangeable
18//!
19//! A rotation replays the conversation so far to the new member. That is
20//! only sound if every member fronts the **same upstream provider serving
21//! the same model**: a transcript carrying one vendor's reasoning items
22//! replayed to another vendor's endpoint is refused (measured: OpenAI-shaped
23//! `encrypted_content` sent to an xAI-backed relay answers `422`). Two
24//! relays in front of the same model are a ring; two different vendors are
25//! not, and nothing here can tell the difference for you.
26//!
27//! Each member keeps its own session state — a Responses provider's response
28//! chain, its websocket, its endpoint knowledge — so no continuation id ever
29//! crosses from one gateway to another. A ring built from two clones of the
30//! *same* provider instance would share that state and defeat this; build
31//! each member separately.
32//!
33//! # Members are whole providers
34//!
35//! A member is a provider, not a URL, because two gateways rarely accept the
36//! same key. Build each one the way you would build it alone — its own base
37//! URL, its own credential — and hand the finished providers to the ring.
38//!
39//! # The policy
40//!
41//! [`GatewayRingPolicy`] says when to leave a member and whether to come
42//! back. The defaults rotate after five consecutive failures and drift back
43//! to the preferred member once it has rested a minute. The rules:
44//!
45//! - **One member answers at a time**: the *current* one. The ring starts on
46//!   the first member, the preferred one.
47//! - **Consecutive failures rotate.** A counted failure on the current member
48//!   increments its streak; reaching
49//!   [`failure_threshold`](GatewayRingPolicy::failure_threshold) rotates the
50//!   ring to the next member. A success resets the streak.
51//! - **A rejection rotates at once.** A member that answers `4xx` — the wrong
52//!   key, a path it does not serve, a request shape it refuses — is not going
53//!   to improve by being asked again, so it costs one failure, not five.
54//! - **A member that was left is on probation when the ring comes back to
55//!   it.** Its first failure rotates again immediately; its first success
56//!   clears the probation. This is what keeps a dead ring cycling quickly
57//!   instead of spending a full streak on each corpse.
58//! - **Coming back is the policy's call.** With a
59//!   [`cooldown`](GatewayRingPolicy::cooldown), the ring drifts back to a more
60//!   preferred member once that member has rested that long; without one, the
61//!   ring is sticky and only reaches a member again by rotating around to it.
62
63mod health;
64#[cfg(test)]
65mod tests;
66
67use std::fmt;
68use std::sync::{Arc, Mutex};
69use std::time::Instant;
70
71use async_trait::async_trait;
72use futures_util::future::BoxFuture;
73
74use crate::definition::ProviderDefinition;
75use crate::error::ProviderError;
76use crate::model::ModelInfo;
77use crate::registry::{Provider, ProviderSession, ProviderSessionScope};
78use crate::request::{CompactionRequest, MemorySummarizeRequest, Request};
79use crate::response::{CompactionResponse, MemorySummarizeResponse};
80use crate::stream::ProviderEventStream;
81
82pub use self::health::{FailureKind, GatewayRingPolicy};
83use self::health::{RingHealth, Selection};
84
85/// A ring with no members has nobody to ask.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
87#[error("a gateway ring needs at least one member")]
88pub struct EmptyGatewayRing;
89
90/// Which member an event is about.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct GatewayMemberRef {
93    /// The member's position in the ring, `0` being the preferred one.
94    pub index: usize,
95    /// The member's display name, or its base URL, or its id — whichever it
96    /// had first. For logs.
97    pub label: String,
98}
99
100/// Something the ring did that a host may want in its logs.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum GatewayRingEvent {
103    /// The current member failed a call.
104    MemberFailed {
105        member: GatewayMemberRef,
106        kind: FailureKind,
107        /// The streak after this failure; `0` when the failure was not
108        /// counted.
109        consecutive_failures: u32,
110        error: String,
111    },
112    /// The ring left one member for the next. The call that triggered it
113    /// continues on `to`.
114    Rotated {
115        from: GatewayMemberRef,
116        to: GatewayMemberRef,
117        /// The streak that caused it; `0` for an immediate rotation.
118        after_failures: u32,
119    },
120    /// The ring came back to a more preferred member whose cooldown elapsed.
121    /// The member is on probation until it answers.
122    Returned { member: GatewayMemberRef },
123    /// A member on probation answered; the ring is settled on it.
124    Recovered { member: GatewayMemberRef },
125}
126
127/// `#0 (http://gateway/)` — the position first, because two members can share
128/// a label and the position is what the configuration that built the ring
129/// says.
130impl fmt::Display for GatewayMemberRef {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write!(f, "#{} ({})", self.index, self.label)
133    }
134}
135
136/// One line per event, so a host's observer can be `|event| log("{event}")`
137/// rather than a `match` every host writes again.
138impl fmt::Display for GatewayRingEvent {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::MemberFailed {
142                member,
143                kind,
144                consecutive_failures,
145                error,
146            } => {
147                let counted = match kind {
148                    FailureKind::Counted => "counted",
149                    FailureKind::Immediate => "rotates at once",
150                    FailureKind::NotTheMembersFault => "not counted: the request's own fault",
151                };
152                write!(
153                    f,
154                    "gateway {member} failed ({counted}; streak {consecutive_failures}): {error}"
155                )
156            }
157            Self::Rotated {
158                from,
159                to,
160                after_failures: 0,
161            } => write!(f, "gateway ring rotated from {from} to {to} at once"),
162            Self::Rotated {
163                from,
164                to,
165                after_failures,
166            } => write!(
167                f,
168                "gateway ring rotated from {from} to {to} after {after_failures} consecutive failures"
169            ),
170            Self::Returned { member } => {
171                write!(f, "gateway ring returned to {member} on probation")
172            }
173            Self::Recovered { member } => write!(f, "gateway ring settled on {member}"),
174        }
175    }
176}
177
178type Observer = Arc<dyn Fn(GatewayRingEvent) + Send + Sync>;
179
180struct Member {
181    provider: Arc<dyn Provider>,
182    label: String,
183}
184
185impl Member {
186    fn new(provider: Arc<dyn Provider>) -> Self {
187        let definition = provider.definition();
188        let label = definition
189            .descriptor
190            .display_name
191            .clone()
192            .or_else(|| definition.base_url.clone())
193            .unwrap_or_else(|| definition.descriptor.id.to_string());
194        Self { provider, label }
195    }
196
197    fn reference(&self, index: usize) -> GatewayMemberRef {
198        GatewayMemberRef {
199            index,
200            label: self.label.clone(),
201        }
202    }
203}
204
205/// An ordered ring of gateways, spoken to one at a time. See the module doc.
206///
207/// Cloning shares everything: the members, their session state, and the
208/// ring's memory of who is answering. Use
209/// [`Provider::fresh_session_scope`] for members with fresh session state;
210/// the ring's memory is shared across scopes regardless, because a gateway
211/// that is down is down for every conversation.
212#[derive(Clone)]
213pub struct GatewayRing {
214    members: Arc<[Member]>,
215    definition: ProviderDefinition,
216    policy: GatewayRingPolicy,
217    health: Arc<Mutex<RingHealth>>,
218    observer: Option<Observer>,
219}
220
221impl fmt::Debug for GatewayRing {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        f.debug_struct("GatewayRing")
224            .field(
225                "members",
226                &self
227                    .members
228                    .iter()
229                    .map(|member| member.label.as_str())
230                    .collect::<Vec<_>>(),
231            )
232            .field("policy", &self.policy)
233            .field("current", &self.health().current())
234            .finish_non_exhaustive()
235    }
236}
237
238impl GatewayRing {
239    /// Builds a ring over `members`, in preference order.
240    ///
241    /// The ring takes its identity — id, wire, capabilities — from the first
242    /// member, and only its display name says there is a ring at all.
243    pub fn new(
244        members: impl IntoIterator<Item = Arc<dyn Provider>>,
245        policy: GatewayRingPolicy,
246    ) -> Result<Self, EmptyGatewayRing> {
247        let members: Arc<[Member]> = members.into_iter().map(Member::new).collect();
248        let first = members.first().ok_or(EmptyGatewayRing)?;
249        let definition = ring_definition(first.provider.definition(), &members);
250        Ok(Self {
251            health: Arc::new(Mutex::new(RingHealth::new(members.len()))),
252            members,
253            definition,
254            policy,
255            observer: None,
256        })
257    }
258
259    /// Reports what the ring does — every failure it counts, every rotation,
260    /// every return — to `observer`. The ring has no logger of its own; this
261    /// is how a rotation reaches a host's logs.
262    #[must_use]
263    pub fn with_observer(
264        self,
265        observer: impl Fn(GatewayRingEvent) + Send + Sync + 'static,
266    ) -> Self {
267        Self {
268            observer: Some(Arc::new(observer)),
269            ..self
270        }
271    }
272
273    /// The policy this ring rotates by.
274    pub fn policy(&self) -> GatewayRingPolicy {
275        self.policy
276    }
277
278    /// The members, in preference order.
279    pub fn members(&self) -> Vec<GatewayMemberRef> {
280        self.members
281            .iter()
282            .enumerate()
283            .map(|(index, member)| member.reference(index))
284            .collect()
285    }
286
287    /// The member the next call would go to, before any cooldown is applied.
288    pub fn active(&self) -> GatewayMemberRef {
289        let index = self.health().current();
290        self.members[index].reference(index)
291    }
292
293    fn health(&self) -> RingHealth {
294        self.health
295            .lock()
296            .unwrap_or_else(std::sync::PoisonError::into_inner)
297            .clone()
298    }
299
300    /// Applies a pure transition to the shared health under the lock.
301    fn transition<T>(&self, transition: impl FnOnce(&RingHealth) -> (RingHealth, T)) -> T {
302        let mut guard = self
303            .health
304            .lock()
305            .unwrap_or_else(std::sync::PoisonError::into_inner);
306        let (next, out) = transition(&guard);
307        *guard = next;
308        out
309    }
310
311    fn emit(&self, event: GatewayRingEvent) {
312        if let Some(observer) = &self.observer {
313            observer(event);
314        }
315    }
316
317    /// Runs `op` against the current member, rotating and re-running it on
318    /// the next member when the failure calls for it, at most once around
319    /// the ring per call.
320    ///
321    /// `arg` is passed through rather than captured so the closure borrows
322    /// nothing of its own: the future it returns then lives as long as the
323    /// member and the argument, which is all the compiler needs to see.
324    async fn with_rotation<A, T>(
325        &self,
326        arg: A,
327        op: impl for<'a> Fn(&'a dyn Provider, &'a A) -> BoxFuture<'a, Result<T, ProviderError>>,
328    ) -> Result<T, ProviderError>
329    where
330        A: Sync,
331    {
332        let mut selection = self.transition(|health| health.select(self.policy, Instant::now()));
333        let mut attempts = 0usize;
334        loop {
335            attempts += 1;
336            let index = selection.index;
337            let member = &self.members[index];
338            if selection.returned {
339                self.emit(GatewayRingEvent::Returned {
340                    member: member.reference(index),
341                });
342            }
343            let probation = self.health().on_probation();
344            match op(member.provider.as_ref(), &arg).await {
345                Ok(value) => {
346                    self.transition(|health| (health.succeeded(index), ()));
347                    if probation {
348                        self.emit(GatewayRingEvent::Recovered {
349                            member: member.reference(index),
350                        });
351                    }
352                    return Ok(value);
353                }
354                Err(error) => {
355                    let kind = classify(&error);
356                    if kind == FailureKind::NotTheMembersFault {
357                        return Err(error);
358                    }
359                    let now = Instant::now();
360                    let rotation =
361                        self.transition(|health| health.failed(index, kind, self.policy, now));
362                    self.emit(GatewayRingEvent::MemberFailed {
363                        member: member.reference(index),
364                        kind,
365                        consecutive_failures: rotation
366                            .map_or_else(|| self.streak_of(index), |r| r.after_failures),
367                        error: error.to_string(),
368                    });
369                    let Some(rotation) = rotation else {
370                        return Err(error);
371                    };
372                    self.emit(GatewayRingEvent::Rotated {
373                        from: member.reference(rotation.from),
374                        to: self.members[rotation.to].reference(rotation.to),
375                        after_failures: rotation.after_failures,
376                    });
377                    if attempts >= self.members.len() {
378                        return Err(error);
379                    }
380                    selection = Selection {
381                        index: rotation.to,
382                        returned: false,
383                    };
384                }
385            }
386        }
387    }
388
389    fn streak_of(&self, index: usize) -> u32 {
390        self.health().streak_of(index)
391    }
392}
393
394/// Sorts a failed call into what the ring should do about it. See
395/// [`FailureKind`].
396pub fn classify(error: &ProviderError) -> FailureKind {
397    use reqwest::StatusCode;
398    match error {
399        ProviderError::Transport(_)
400        | ProviderError::Decode(_)
401        | ProviderError::Deserialize(_)
402        | ProviderError::Retryable { .. }
403        | ProviderError::InvalidResponse(_)
404        | ProviderError::MalformedStream(_) => FailureKind::Counted,
405        ProviderError::Http { status, .. } => {
406            if *status == StatusCode::TOO_MANY_REQUESTS || *status == StatusCode::REQUEST_TIMEOUT {
407                FailureKind::Counted
408            } else if status.is_client_error() {
409                FailureKind::Immediate
410            } else {
411                FailureKind::Counted
412            }
413        }
414        ProviderError::ContextLengthExceeded { .. }
415        | ProviderError::Serialize(_)
416        | ProviderError::InvalidRequest(_)
417        | ProviderError::UnsupportedCapability(_) => FailureKind::NotTheMembersFault,
418    }
419}
420
421fn ring_definition(first: ProviderDefinition, members: &[Member]) -> ProviderDefinition {
422    let labels = members
423        .iter()
424        .map(|member| member.label.as_str())
425        .collect::<Vec<_>>()
426        .join(" → ");
427    let mut definition = first;
428    definition.descriptor.display_name = Some(format!("gateway ring: {labels}"));
429    definition
430}
431
432#[async_trait]
433impl Provider for GatewayRing {
434    async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
435        self.with_rotation((), |member, ()| member.list_models())
436            .await
437    }
438
439    async fn create_session(&self) -> Result<Box<dyn ProviderSession>, ProviderError> {
440        Ok(Box::new(GatewayRingSession(self.clone())))
441    }
442
443    fn definition(&self) -> ProviderDefinition {
444        self.definition.clone()
445    }
446
447    fn fresh_session_scope(&self) -> Result<ProviderSessionScope, ProviderError> {
448        let members = self
449            .members
450            .iter()
451            .map(|member| {
452                member.provider.fresh_session_scope().map(|scope| Member {
453                    provider: Arc::new(scope),
454                    label: member.label.clone(),
455                })
456            })
457            .collect::<Result<Arc<[Member]>, _>>()?;
458        Ok(ProviderSessionScope::new(Self {
459            members,
460            definition: self.definition.clone(),
461            policy: self.policy,
462            health: Arc::clone(&self.health),
463            observer: self.observer.clone(),
464        }))
465    }
466
467    async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
468        self.with_rotation(request, |member, request| member.stream(request.clone()))
469            .await
470    }
471
472    async fn compact(
473        &self,
474        request: CompactionRequest<'_>,
475    ) -> Result<CompactionResponse, ProviderError> {
476        self.with_rotation(request, |member, request| member.compact(request.clone()))
477            .await
478    }
479
480    async fn summarize_memories(
481        &self,
482        request: MemorySummarizeRequest<'_>,
483    ) -> Result<MemorySummarizeResponse, ProviderError> {
484        self.with_rotation(request, |member, request| {
485            member.summarize_memories(request.clone())
486        })
487        .await
488    }
489}
490
491/// A session over the ring. Every call goes back through the ring so the
492/// rotation logic runs once, on the provider, rather than being copied here.
493struct GatewayRingSession(GatewayRing);
494
495#[async_trait]
496impl ProviderSession for GatewayRingSession {
497    async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
498        Provider::stream(&self.0, request).await
499    }
500
501    async fn compact(
502        &self,
503        request: CompactionRequest<'_>,
504    ) -> Result<CompactionResponse, ProviderError> {
505        Provider::compact(&self.0, request).await
506    }
507
508    async fn summarize_memories(
509        &self,
510        request: MemorySummarizeRequest<'_>,
511    ) -> Result<MemorySummarizeResponse, ProviderError> {
512        Provider::summarize_memories(&self.0, request).await
513    }
514}