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}