Skip to main content

amberfork_replay/
upstream.rs

1//! The live-relay seam.
2//!
3//! Replay serves recorded responses until the re-execution branches off the tape; past that
4//! point a request has no recorded answer and must go to a real provider. [`Upstream`] is that
5//! one operation, behind a trait so the test suite can substitute an in-process script and keep
6//! `cargo test --workspace` fully offline — the same discipline that quarantines `tokio` to the
7//! I/O-edge crates.
8
9use amberfork_record::{Body, CapturedRequest, CapturedResponse, retain_response_headers};
10use std::collections::VecDeque;
11use std::fmt;
12use std::future::Future;
13use std::sync::{Mutex, PoisonError};
14
15/// Relay one request the recording could not answer to a live provider.
16///
17/// A single method, mirroring `CaptureProxy`'s forward step but in the replay direction. It is
18/// async and `Send` because the production implementation performs network I/O inside the
19/// listener's task; the trait is written in terms of the cassette's own `CapturedRequest` /
20/// `CapturedResponse` so a relayed exchange drops straight back onto the re-executed tape.
21///
22/// Expressed as a native async trait (return-position `impl Future`, not the `async-trait`
23/// crate): a call site picks its implementation at compile time — [`ScriptedUpstream`] in tests,
24/// a live `reqwest` client in production — so no `dyn`, and no per-relay allocation, is needed.
25pub trait Upstream: Send + Sync {
26    /// Send `request` upstream and return the response.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`UpstreamError`] when a response cannot be produced — for the scripted stub,
31    /// when it has no more responses queued; for the live relay (later slice), a transport
32    /// failure.
33    fn send(
34        &self,
35        request: &CapturedRequest,
36    ) -> impl Future<Output = Result<CapturedResponse, UpstreamError>> + Send;
37}
38
39/// Why a live relay could not produce a response.
40#[derive(Debug)]
41#[non_exhaustive]
42pub enum UpstreamError {
43    /// The in-process scripted stub ran out of queued responses — a test drove more cache-miss
44    /// relays than it scripted.
45    Exhausted,
46    /// The live relay to the real provider failed at the transport level — the origin was
47    /// unreachable, the connection dropped, or the response body could not be read.
48    Transport(reqwest::Error),
49}
50
51impl fmt::Display for UpstreamError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Exhausted => f.write_str("scripted upstream has no more responses"),
55            Self::Transport(source) => write!(f, "live relay to the provider failed: {source}"),
56        }
57    }
58}
59
60impl std::error::Error for UpstreamError {
61    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
62        match self {
63            Self::Exhausted => None,
64            Self::Transport(source) => Some(source),
65        }
66    }
67}
68
69/// The live provider relay: forward a cache-miss to the real API and return its answer.
70///
71/// The production [`Upstream`], the counterpart to [`ScriptedUpstream`]. Once a re-execution
72/// branches off the tape — which a counterfactual re-run does the moment it passes the patched step
73/// — the request has no recorded answer and must reach a real provider. `LiveUpstream` forwards it
74/// verbatim to the configured origin and captures the round trip into a [`CapturedResponse`], the
75/// same shape the tape holds, so the relayed turn drops straight onto the re-executed cassette.
76///
77/// The client is injected rather than built here so a `verify` can share one connection pool across
78/// all N re-runs; [`reqwest::Client`] is internally reference-counted, so cloning it per run is
79/// cheap.
80#[derive(Debug, Clone)]
81pub struct LiveUpstream {
82    client: reqwest::Client,
83    origin: String,
84}
85
86impl LiveUpstream {
87    /// A relay that forwards misses to `origin` (e.g. `https://api.openai.com`) over `client`.
88    /// A trailing slash on the origin is trimmed so `origin + path` never doubles it.
89    #[must_use]
90    pub fn new(client: reqwest::Client, origin: impl Into<String>) -> Self {
91        Self {
92            client,
93            origin: origin.into().trim_end_matches('/').to_string(),
94        }
95    }
96}
97
98impl Upstream for LiveUpstream {
99    fn send(
100        &self,
101        request: &CapturedRequest,
102    ) -> impl Future<Output = Result<CapturedResponse, UpstreamError>> + Send {
103        // Build the outbound request synchronously, then move it into the future — so the future
104        // borrows neither `self` nor `request` and is straightforwardly `Send`.
105        let method =
106            reqwest::Method::from_bytes(request.method.as_bytes()).unwrap_or(reqwest::Method::POST);
107        let mut outbound = self
108            .client
109            .request(method, format!("{}{}", self.origin, request.path));
110        for (name, value) in &request.headers {
111            // `host` names the loopback proxy's own origin; forwarding it would point the provider
112            // back at us. reqwest sets the correct host for the outbound origin itself.
113            if name.eq_ignore_ascii_case("host") {
114                continue;
115            }
116            outbound = outbound.header(name, value);
117        }
118        let body = match &request.body {
119            // Re-serialize compactly; `to_vec` on a parsed body is infallible in practice.
120            Body::Json(value) => serde_json::to_vec(value).unwrap_or_default(),
121            Body::Text(text) => text.clone().into_bytes(),
122        };
123        let outbound = outbound.body(body);
124
125        async move {
126            let response = outbound.send().await.map_err(UpstreamError::Transport)?;
127            // Status and body are forwarded verbatim: a 429 or a 500 is a genuine divergence signal,
128            // not a relay failure. Only a transport-level fault (unreachable, dropped) is an error.
129            let status = response.status().as_u16();
130            let headers = retain_response_headers(
131                response
132                    .headers()
133                    .iter()
134                    .map(|(name, value)| (name.as_str(), value.as_bytes())),
135            );
136            let body = response.bytes().await.map_err(UpstreamError::Transport)?;
137            Ok(CapturedResponse {
138                status,
139                headers,
140                body: Body::from_bytes(&body),
141            })
142        }
143    }
144}
145
146/// An in-process [`Upstream`] that serves a fixed script of responses in order.
147///
148/// This is the seam that keeps replay tests offline: instead of a network provider, a re-run's
149/// cache-miss relays are answered from a queue the test set up. It hands out responses in FIFO
150/// order and reports [`UpstreamError::Exhausted`] once the script runs dry, so a test that
151/// under-scripts fails loudly rather than serving a stale answer.
152#[derive(Debug)]
153pub struct ScriptedUpstream {
154    responses: Mutex<VecDeque<CapturedResponse>>,
155}
156
157impl ScriptedUpstream {
158    /// A stub that will serve `responses` in order on successive cache misses.
159    #[must_use]
160    pub fn new(responses: impl IntoIterator<Item = CapturedResponse>) -> Self {
161        Self {
162            responses: Mutex::new(responses.into_iter().collect()),
163        }
164    }
165}
166
167impl Upstream for ScriptedUpstream {
168    fn send(
169        &self,
170        _request: &CapturedRequest,
171    ) -> impl Future<Output = Result<CapturedResponse, UpstreamError>> + Send {
172        // The next scripted response is resolved synchronously and moved into the returned
173        // future; the async wrapper exists only to satisfy the trait's I/O-shaped signature, so
174        // it borrows neither `self` nor `request`.
175        let next = self
176            .responses
177            .lock()
178            .unwrap_or_else(PoisonError::into_inner)
179            .pop_front()
180            .ok_or(UpstreamError::Exhausted);
181        async move { next }
182    }
183}