Skip to main content

inferlab_proxy/
core.rs

1//! Shared HTTP mechanics for the built-in disaggregated-serving proxies.
2//!
3//! Proxy-specific protocol bodies remain in their owning modules.
4
5use crate::error::ProxyError as ProxyLifecycleError;
6use async_stream::try_stream;
7use axum::Json;
8use axum::body::Body;
9use axum::http::{HeaderMap, Response, StatusCode, header};
10use axum::response::IntoResponse;
11use bytes::Bytes;
12use futures_util::{FutureExt, Stream, StreamExt};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::env;
16use std::fmt;
17use std::future::Future;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::time::Duration;
20use tokio::task::JoinHandle;
21
22/// Build a multi-threaded Tokio runtime and drive `run_async` to completion.
23pub fn run<F, Fut>(run_async: F) -> Result<(), ProxyLifecycleError>
24where
25    F: FnOnce() -> Fut,
26    Fut: Future<Output = Result<(), ProxyLifecycleError>>,
27{
28    let runtime = tokio::runtime::Builder::new_multi_thread()
29        .enable_all()
30        .build()
31        .map_err(|error| ProxyLifecycleError::Lifecycle {
32            message: format!("failed to create proxy tokio runtime: {error}"),
33        })?;
34    runtime.block_on(run_async())
35}
36
37/// Healthcheck response body shared by the proxies.
38#[derive(Serialize)]
39pub struct ProxyHealthcheckResponse {
40    pub ready: bool,
41    pub prefill_instances: usize,
42    pub decode_instances: usize,
43}
44
45/// The shared `/healthcheck` payload: 200 once ready, 503 before, with the configured instance counts.
46pub(crate) fn healthcheck_response(
47    ready: bool,
48    prefill_instances: usize,
49    decode_instances: usize,
50) -> (StatusCode, Json<ProxyHealthcheckResponse>) {
51    let status = if ready {
52        StatusCode::OK
53    } else {
54        StatusCode::SERVICE_UNAVAILABLE
55    };
56    (
57        status,
58        Json(ProxyHealthcheckResponse {
59            ready,
60            prefill_instances,
61            decode_instances,
62        }),
63    )
64}
65
66/// Validate that both role endpoint lists are non-empty, naming the proxy in the validation message.
67pub(crate) fn require_endpoints(
68    proxy_name: &'static str,
69    prefill_is_empty: bool,
70    decode_is_empty: bool,
71) -> Result<(), ProxyLifecycleError> {
72    if prefill_is_empty {
73        return Err(ProxyLifecycleError::Invalid {
74            message: format!("{proxy_name} requires at least one prefill endpoint"),
75        });
76    }
77    if decode_is_empty {
78        return Err(ProxyLifecycleError::Invalid {
79            message: format!("{proxy_name} requires at least one decode endpoint"),
80        });
81    }
82    Ok(())
83}
84
85/// Build the shared pooled client, naming the proxy in the failure message.
86pub(crate) fn pooled_client(
87    proxy_name: &'static str,
88) -> Result<reqwest::Client, ProxyLifecycleError> {
89    build_pooled_client().map_err(|error| ProxyLifecycleError::Io {
90        message: format!("failed to create {proxy_name} HTTP client: {error}"),
91    })
92}
93
94/// Bind `host:port` and serve `router`, naming the proxy in bind/serve failure messages.
95pub(crate) async fn serve_router(
96    proxy_name: &'static str,
97    host: &str,
98    port: u16,
99    router: axum::Router,
100) -> Result<(), ProxyLifecycleError> {
101    let listener = tokio::net::TcpListener::bind((host, port))
102        .await
103        .map_err(|error| ProxyLifecycleError::Io {
104            message: format!("failed to bind {proxy_name} on {host}:{port}: {error}"),
105        })?;
106    axum::serve(listener, router)
107        .await
108        .map_err(|error| ProxyLifecycleError::Io {
109            message: format!("{proxy_name} server failed: {error}"),
110        })
111}
112
113/// Poll every backend `url` at `path` once per second until each answers with a success status.
114pub(crate) async fn await_backends(client: reqwest::Client, urls: Vec<String>, path: &'static str) {
115    let waits = urls
116        .into_iter()
117        .map(|url| await_backend(client.clone(), url, path));
118    futures_util::future::join_all(waits).await;
119}
120
121async fn await_backend(client: reqwest::Client, url: String, path: &'static str) {
122    loop {
123        if client
124            .get(join_path(&url, path))
125            .send()
126            .await
127            .is_ok_and(|response| response.status().is_success())
128        {
129            return;
130        }
131        tokio::time::sleep(Duration::from_secs(1)).await;
132    }
133}
134
135/// Collect the fan-out target URLs shared by the reset/flush sweeps and the
136/// readiness wait: every prefill replica URL followed by every decode URL.
137pub(crate) fn fanout_target_urls<'a>(
138    prefill_urls: impl IntoIterator<Item = &'a str>,
139    decode_urls: impl IntoIterator<Item = &'a str>,
140) -> Vec<String> {
141    prefill_urls
142        .into_iter()
143        .chain(decode_urls)
144        .map(str::to_owned)
145        .collect()
146}
147
148/// Start a proxy response builder that mirrors the upstream response's
149/// status and (when present) content-type.
150pub(crate) fn upstream_response_builder(
151    response: &reqwest::Response,
152) -> Result<axum::http::response::Builder, ProxyHttpError> {
153    let mut builder = Response::builder().status(status_code(response.status())?);
154    if let Some(content_type) = response
155        .headers()
156        .get(reqwest::header::CONTENT_TYPE)
157        .and_then(|value| value.to_str().ok())
158    {
159        builder = builder.header(header::CONTENT_TYPE, content_type);
160    }
161    Ok(builder)
162}
163
164/// Finish a proxy response builder with `body`.
165pub(crate) fn response_body(
166    builder: axum::http::response::Builder,
167    body: Body,
168) -> Result<Response<Body>, ProxyHttpError> {
169    builder.body(body).map_err(|error| {
170        ProxyHttpError::internal(format!("failed to build proxy response: {error}"))
171    })
172}
173
174/// Forward an upstream response body verbatim, preserving status and
175/// content-type.
176pub async fn forward_response(
177    response: reqwest::Response,
178) -> Result<Response<Body>, ProxyHttpError> {
179    let builder = upstream_response_builder(&response)?;
180    let bytes = response
181        .bytes()
182        .await
183        .map_err(|error| ProxyHttpError::upstream("upstream response body read failed", error))?;
184    response_body(builder, Body::from(bytes))
185}
186
187/// Convert an unsuccessful upstream response into a `502 Bad Gateway`
188/// [`ProxyHttpError`] that captures the upstream status and body.
189pub async fn upstream_status_error(context: &str, response: reqwest::Response) -> ProxyHttpError {
190    let status = response.status();
191    let body = match response.text().await {
192        Ok(text) => text,
193        Err(error) => format!("<failed to read upstream error body: {error}>"),
194    };
195    ProxyHttpError::status(
196        StatusCode::BAD_GATEWAY,
197        format!("{context} returned HTTP {status}: {body}"),
198    )
199}
200
201/// Resolve the outbound `Authorization` header from the inbound request or the
202/// `OPENAI_API_KEY` environment variable.
203pub fn outbound_authorization(headers: &HeaderMap) -> Option<String> {
204    headers
205        .get(header::AUTHORIZATION)
206        .and_then(|value| value.to_str().ok())
207        .map(str::to_owned)
208        .or_else(|| {
209            env::var("OPENAI_API_KEY")
210                .ok()
211                .map(|key| format!("Bearer {key}"))
212        })
213}
214
215/// Join a base URL with a path, normalizing a single trailing slash on the
216/// base.
217pub fn join_path(base: &str, path: &str) -> String {
218    format!("{}{}", base.trim_end_matches('/'), path)
219}
220
221/// Convert a `reqwest` status code into an `axum`/`http` status code.
222pub fn status_code(status: reqwest::StatusCode) -> Result<StatusCode, ProxyHttpError> {
223    StatusCode::from_u16(status.as_u16())
224        .map_err(|error| ProxyHttpError::internal(format!("invalid upstream status code: {error}")))
225}
226
227/// Advance a round-robin cursor and return the selected index into a non-empty
228/// target list. Shared by all proxies' prefill/decode selection; each proxy
229/// keeps its own cursor and target list (which stay local).
230pub(crate) fn round_robin_index(cursor: &AtomicUsize, len: usize) -> usize {
231    cursor.fetch_add(1, Ordering::SeqCst) % len
232}
233
234/// Build and send a JSON POST to `url` with an optional `X-Request-Id`, any
235/// `extra_headers`, and an optional `Authorization`, returning the response or a
236/// [`ProxyHttpError`] on transport or non-success status. `context` names the
237/// call in error messages (e.g. "decode request"). Owns the transport for every
238/// built-in proxy POST. Proxy-specific request ids and headers are optional.
239pub(crate) async fn send_json_post(
240    client: reqwest::Client,
241    url: String,
242    body: &Value,
243    request_id: Option<&str>,
244    authorization: Option<&str>,
245    extra_headers: &[(&str, String)],
246    context: &'static str,
247) -> Result<reqwest::Response, ProxyHttpError> {
248    let response = send_json_post_status(
249        client,
250        url,
251        body,
252        request_id,
253        authorization,
254        extra_headers,
255        context,
256    )
257    .await?;
258    if !response.status().is_success() {
259        return Err(upstream_status_error(context, response).await);
260    }
261    Ok(response)
262}
263
264/// Like [`send_json_post`], but returns the response for any upstream status:
265/// fan-out callers record per-target statuses instead of failing fast on the
266/// first non-success upstream response.
267pub(crate) async fn send_json_post_status(
268    client: reqwest::Client,
269    url: String,
270    body: &Value,
271    request_id: Option<&str>,
272    authorization: Option<&str>,
273    extra_headers: &[(&str, String)],
274    context: &'static str,
275) -> Result<reqwest::Response, ProxyHttpError> {
276    let mut request = client.post(url).json(body);
277    if let Some(request_id) = request_id {
278        request = request.header("X-Request-Id", request_id);
279    }
280    // Extra headers precede `Authorization`: header insertion order reaches
281    // the wire, and Mooncake's prefill always sent its rank header first.
282    for (name, value) in extra_headers {
283        request = request.header(*name, value);
284    }
285    if let Some(authorization) = authorization {
286        request = request.header(reqwest::header::AUTHORIZATION, authorization);
287    }
288    request
289        .send()
290        .await
291        .map_err(|error| ProxyHttpError::upstream(&format!("{context} failed"), error))
292}
293
294/// A per-process monotonic request id, `"{pid}-{n}"`, drawn from a proxy-owned
295/// counter. Shared by the vLLM proxies so the id scheme has one home.
296pub(crate) fn next_request_id(counter: &AtomicUsize) -> String {
297    let value = counter.fetch_add(1, Ordering::SeqCst);
298    format!("{}-{value}", std::process::id())
299}
300
301/// Build the outbound HTTP client shared by the proxies, with the pool tuning
302/// (unbounded idle connections per host) both require. Returns the raw
303/// `reqwest` error so each proxy keeps its own construction-failure message.
304pub(crate) fn build_pooled_client() -> reqwest::Result<reqwest::Client> {
305    reqwest::Client::builder()
306        .pool_max_idle_per_host(usize::MAX)
307        .build()
308}
309
310/// Per-target ceiling for cache reset/flush/prime fan-out operations. The
311/// pooled client itself carries no timeout because it also serves
312/// long-lived streaming decode requests; fan-out targets instead get this
313/// per-target bound so one hung engine cannot stall every remaining rank
314/// ([[RFC-0004:C-BENCH-CACHE-STATE]]).
315pub(crate) const FANOUT_TARGET_TIMEOUT: Duration = Duration::from_secs(60);
316
317/// Failure detail of one reset/flush fan-out target.
318#[derive(Debug, Deserialize, Serialize)]
319pub struct FanoutFailure {
320    pub url: String,
321    pub error: String,
322}
323
324/// Aggregated response of the cache reset/flush fan-out endpoints. SGLang's
325/// `flush_cache` and the vLLM proxies' `reset_prefix_cache` share this wire
326/// contract.
327#[derive(Debug, Deserialize, Serialize)]
328pub struct ResetPrefixCacheResponse {
329    pub successful: Vec<String>,
330    pub failed: Vec<FanoutFailure>,
331}
332
333/// Aggregated response of the prefix-cache conditioning fan-out endpoint.
334/// The control plane deserializes this exact shape, so the fields are the
335/// cross-process contract.
336#[derive(Debug, Deserialize, Serialize)]
337pub struct PrimePrefixCacheResponse {
338    pub targets: Vec<PrimePrefixCacheTarget>,
339}
340
341/// One fanned-out conditioning flow: the prefill replica URL and the pinned
342/// data-parallel rank, with the observed status or the failure detail.
343#[derive(Debug, Deserialize, Serialize)]
344pub struct PrimePrefixCacheTarget {
345    pub url: String,
346    pub rank: u32,
347    pub http_status: Option<u16>,
348    pub elapsed_ms: u64,
349    pub error: Option<String>,
350}
351
352/// Failure of one fanned-out conditioning flow: the upstream status when a
353/// response was observed, plus the failure detail.
354pub(crate) struct PrimeFlowFailure {
355    pub http_status: Option<u16>,
356    pub error: String,
357}
358
359impl PrimeFlowFailure {
360    pub(crate) fn transport(error: ProxyHttpError) -> Self {
361        Self {
362            http_status: None,
363            error: error.to_string(),
364        }
365    }
366
367    pub(crate) fn status(status: u16, detail: String) -> Self {
368        Self {
369            http_status: Some(status),
370            error: detail,
371        }
372    }
373}
374
375/// Read a fanned-out conditioning response to text, requiring a 2xx status.
376/// The body is captured either way so a non-2xx status reports it as the
377/// failure detail; a body read failure is a transport failure. Returns the
378/// upstream status and body on success.
379pub(crate) async fn expect_2xx(
380    context: &'static str,
381    response: reqwest::Response,
382) -> Result<(u16, String), PrimeFlowFailure> {
383    let status = response.status().as_u16();
384    let text = response.text().await.map_err(|error| {
385        PrimeFlowFailure::transport(ProxyHttpError::upstream(
386            &format!("{context} response read failed"),
387            error,
388        ))
389    })?;
390    if !(200..300).contains(&status) {
391        return Err(PrimeFlowFailure::status(
392            status,
393            format!("{context} returned HTTP {status}: {text}"),
394        ));
395    }
396    Ok((status, text))
397}
398
399/// Identity of one prime fan-out target: the prefill replica URL and the
400/// data-parallel rank the flow pins.
401pub(crate) trait PrimeFanoutTarget {
402    fn url(&self) -> &str;
403    fn rank(&self) -> u32;
404}
405
406/// A prefill replica with a static (config-issued) data-parallel size, as
407/// opposed to Mooncake's discovered per-rank engines.
408pub(crate) trait PrimeReplica {
409    fn url(&self) -> &str;
410    fn data_parallel_size(&self) -> u32;
411}
412
413/// One prime fan-out target over a static-size replica: the replica and the
414/// pinned data-parallel rank.
415pub(crate) struct RankedPrimeTarget<R> {
416    pub replica: R,
417    pub rank: u32,
418}
419
420impl<R: PrimeReplica> PrimeFanoutTarget for RankedPrimeTarget<R> {
421    fn url(&self) -> &str {
422        self.replica.url()
423    }
424
425    fn rank(&self) -> u32 {
426        self.rank
427    }
428}
429
430/// Enumerate the prime fan-out targets for static-size replicas, expanding
431/// each replica over its data-parallel ranks (at least one).
432pub(crate) fn ranked_prime_targets<R: PrimeReplica + Clone>(
433    replicas: &[R],
434) -> Vec<RankedPrimeTarget<R>> {
435    let mut targets = Vec::new();
436    for replica in replicas {
437        for rank in 0..replica.data_parallel_size().max(1) {
438            targets.push(RankedPrimeTarget {
439                replica: replica.clone(),
440                rank,
441            });
442        }
443    }
444    targets
445}
446
447/// Run the reset/flush fan-out skeleton: the engine module enumerates the
448/// target base URLs and names its endpoint (`path`) and operation; target
449/// execution, the per-target timeout, and the response aggregation (200 when
450/// every target succeeded, 206 on partial failure) live here. An empty
451/// target set is a 502 — "no targets" must not be conflated with success.
452pub(crate) async fn run_sweep_fanout(
453    client: reqwest::Client,
454    operation: &'static str,
455    path: &'static str,
456    targets: Vec<String>,
457    authorization: Option<String>,
458) -> Response<Body> {
459    if targets.is_empty() {
460        return empty_fanout_failure(operation);
461    }
462    let attempts = targets
463        .into_iter()
464        .map(|url| sweep_target(client.clone(), operation, path, url, authorization.clone()));
465    let mut successful = Vec::new();
466    let mut failed = Vec::new();
467    for result in futures_util::future::join_all(attempts).await {
468        match result {
469            Ok(url) => successful.push(url),
470            Err(failure) => failed.push(failure),
471        }
472    }
473    let status = if failed.is_empty() {
474        StatusCode::OK
475    } else {
476        StatusCode::PARTIAL_CONTENT
477    };
478    (
479        status,
480        Json(ResetPrefixCacheResponse { successful, failed }),
481    )
482        .into_response()
483}
484
485async fn sweep_target(
486    client: reqwest::Client,
487    operation: &'static str,
488    path: &'static str,
489    url: String,
490    authorization: Option<String>,
491) -> Result<String, FanoutFailure> {
492    let endpoint = join_path(&url, path);
493    let mut request = client.post(endpoint).timeout(FANOUT_TARGET_TIMEOUT);
494    if let Some(authorization) = authorization {
495        request = request.header(reqwest::header::AUTHORIZATION, authorization);
496    }
497    let response = request.send().await.map_err(|error| FanoutFailure {
498        url: url.clone(),
499        error: format!("{operation} request failed: {error}"),
500    })?;
501    // A 206 from an upstream that is itself an aggregating frontend reports
502    // partial failure, not success.
503    if response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
504        Ok(url)
505    } else {
506        let status = response.status();
507        let detail = response
508            .text()
509            .await
510            .unwrap_or_else(|error| format!("failed to read response body: {error}"));
511        Err(FanoutFailure {
512            url,
513            error: format!("HTTP {status}: {detail}"),
514        })
515    }
516}
517
518/// Run the prefix-cache conditioning fan-out skeleton: the engine module
519/// enumerates the (replica, rank) targets and supplies the per-target
520/// conditioning flow; sequential target execution with a per-target timeout
521/// and the response aggregation (200 when every flow succeeded, 206 on
522/// partial failure) live here. An empty target set is a 502 — "no targets"
523/// must not be conflated with success.
524pub(crate) async fn run_prime_fanout<T, F, Fut>(
525    operation: &'static str,
526    targets: Vec<T>,
527    execute: F,
528) -> Response<Body>
529where
530    T: PrimeFanoutTarget,
531    F: FnMut(T) -> Fut,
532    Fut: Future<Output = Result<u16, PrimeFlowFailure>>,
533{
534    run_prime_fanout_with_timeout(operation, targets, execute, FANOUT_TARGET_TIMEOUT).await
535}
536
537async fn run_prime_fanout_with_timeout<T, F, Fut>(
538    operation: &'static str,
539    targets: Vec<T>,
540    mut execute: F,
541    target_timeout: Duration,
542) -> Response<Body>
543where
544    T: PrimeFanoutTarget,
545    F: FnMut(T) -> Fut,
546    Fut: Future<Output = Result<u16, PrimeFlowFailure>>,
547{
548    if targets.is_empty() {
549        return empty_fanout_failure(operation);
550    }
551    let mut results = Vec::new();
552    for target in targets {
553        let url = target.url().to_owned();
554        let rank = target.rank();
555        let started = std::time::Instant::now();
556        let outcome = tokio::time::timeout(target_timeout, execute(target)).await;
557        let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
558        results.push(match outcome {
559            Ok(Ok(status)) => PrimePrefixCacheTarget {
560                url,
561                rank,
562                http_status: Some(status),
563                elapsed_ms,
564                error: None,
565            },
566            Ok(Err(failure)) => PrimePrefixCacheTarget {
567                url,
568                rank,
569                http_status: failure.http_status,
570                elapsed_ms,
571                error: Some(failure.error),
572            },
573            Err(_elapsed) => PrimePrefixCacheTarget {
574                url,
575                rank,
576                http_status: None,
577                elapsed_ms,
578                error: Some(format!(
579                    "{operation} timed out after {}s",
580                    target_timeout.as_secs()
581                )),
582            },
583        });
584    }
585    let status = if results.iter().all(|target| target.error.is_none()) {
586        StatusCode::OK
587    } else {
588        StatusCode::PARTIAL_CONTENT
589    };
590    (status, Json(PrimePrefixCacheResponse { targets: results })).into_response()
591}
592
593/// "No targets" is a proxy-side failure (502 with an explicit error), never
594/// a 200/206 aggregate: an empty fan-out primes or resets nothing.
595fn empty_fanout_failure(operation: &str) -> Response<Body> {
596    ProxyHttpError::status(
597        StatusCode::BAD_GATEWAY,
598        format!("{operation} fan-out has no targets: no prefill replica or data-parallel rank is available"),
599    )
600    .into_response()
601}
602
603/// Per-request error of the HTTP handlers: an HTTP status plus a message.
604#[derive(Debug)]
605pub struct ProxyHttpError {
606    status: StatusCode,
607    message: String,
608}
609
610impl ProxyHttpError {
611    pub fn status(status: StatusCode, message: impl Into<String>) -> Self {
612        Self {
613            status,
614            message: message.into(),
615        }
616    }
617
618    pub fn upstream(context: &str, error: reqwest::Error) -> Self {
619        Self::status(StatusCode::BAD_GATEWAY, format!("{context}: {error}"))
620    }
621
622    pub fn internal(message: impl Into<String>) -> Self {
623        Self::status(StatusCode::INTERNAL_SERVER_ERROR, message)
624    }
625}
626
627impl fmt::Display for ProxyHttpError {
628    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
629        write!(formatter, "{}", self.message)
630    }
631}
632
633impl std::error::Error for ProxyHttpError {}
634
635impl IntoResponse for ProxyHttpError {
636    fn into_response(self) -> axum::response::Response {
637        let body = Json(ProxyErrorResponse {
638            error: self.message,
639        });
640        (self.status, body).into_response()
641    }
642}
643
644#[derive(Serialize)]
645pub struct ProxyErrorResponse {
646    pub error: String,
647}
648
649/// What happens to the prefill task when the client drops the decode
650/// response stream before prefill completes.
651#[derive(Clone, Copy, Debug)]
652pub(crate) enum OnClientDrop {
653    /// Abort the prefill task (via [`AbortOnDrop`]). The default: once the
654    /// client is gone, the orphaned prefill request is cancelled.
655    Abort,
656    /// Leave the prefill task running: it drains to completion in the
657    /// background. Required when aborting prefill mid-flight would strand
658    /// the paired decode-side engine request (for example the SGLang
659    /// prefill/decode bootstrap room, where the decode engine waits for KV
660    /// that a cancelled prefill would never deliver).
661    Detach,
662}
663
664/// Stream a decode response body while a concurrently-running prefill task
665/// completes. Used by proxies whose backend protocol starts both roles
666/// together; the vLLM NIXL proxy instead forwards them sequentially.
667/// `on_client_drop` selects the prefill task's fate when the client drops
668/// the response before prefill finishes (see [`OnClientDrop`]); once prefill
669/// completes any armed abort is disarmed, and a prefill failure surfaces as
670/// a stream error.
671pub(crate) fn stream_decode_response(
672    response: reqwest::Response,
673    prefill_task: JoinHandle<Result<(), ProxyHttpError>>,
674    on_client_drop: OnClientDrop,
675) -> Result<Response<Body>, ProxyHttpError> {
676    let builder = upstream_response_builder(&response)?;
677    let stream = decode_response_stream(response.bytes_stream(), prefill_task, on_client_drop);
678    response_body(builder, Body::from_stream(stream))
679}
680
681/// Stream one successful upstream response without waiting for another role.
682pub(crate) fn stream_response(
683    response: reqwest::Response,
684) -> Result<Response<Body>, ProxyHttpError> {
685    let builder = upstream_response_builder(&response)?;
686    let stream = response
687        .bytes_stream()
688        .map(|chunk| chunk.map_err(|error| stream_error(format!("decode stream failed: {error}"))));
689    response_body(builder, Body::from_stream(stream))
690}
691
692/// The decode byte stream, generic over the decode stream and its error type so
693/// it can be exercised without a live `reqwest::Response`. Yields decode bytes
694/// in arrival order; concurrently drives `prefill_task` to completion and, per
695/// `on_client_drop`, aborts or detaches it if the consumer drops the stream
696/// before prefill finishes. On decode EOF before prefill completes, prefill is
697/// awaited and its error (if any) surfaces.
698pub(crate) fn decode_response_stream<S, E>(
699    decode_stream: S,
700    prefill_task: JoinHandle<Result<(), ProxyHttpError>>,
701    on_client_drop: OnClientDrop,
702) -> impl Stream<Item = std::result::Result<Bytes, std::io::Error>>
703where
704    S: Stream<Item = std::result::Result<Bytes, E>> + Unpin,
705    E: fmt::Display,
706{
707    let prefill_abort = prefill_task.abort_handle();
708    try_stream! {
709        let mut decode_stream = decode_stream;
710        let mut prefill_task = prefill_task;
711        // Under `OnClientDrop::Detach` no abort guard is armed at all: dropping
712        // the stream (client disconnect) leaves the prefill task draining to
713        // completion in the background.
714        let mut prefill_abort = match on_client_drop {
715            OnClientDrop::Abort => Some(AbortOnDrop::new(prefill_abort)),
716            OnClientDrop::Detach => None,
717        };
718        let mut prefill_done = false;
719        loop {
720            match next_stream_event(&mut prefill_task, &mut decode_stream, prefill_done).await {
721                StreamEvent::Prefill(prefill) => {
722                    prefill_done = true;
723                    // One-time tie-break: if a decode item was already ready at the
724                    // instant prefill completed, handle that single item before
725                    // surfacing the prefill outcome. `now_or_never()` polls (and so
726                    // consumes) the item, so it must be matched exhaustively: deliver
727                    // a ready chunk, and PROPAGATE a ready decode error (an Ok-only
728                    // match would drop it and truncate the response into a clean 200).
729                    // EOF / not-ready fall through to the
730                    // prefill outcome; decode is never indefinitely preferred.
731                    match decode_stream.next().now_or_never() {
732                        Some(Some(Ok(bytes))) => yield bytes,
733                        Some(Some(Err(error))) => {
734                            Err(stream_error(format!("decode stream failed: {error}")))?;
735                        }
736                        Some(None) | None => {}
737                    }
738                    prefill
739                        .map_err(join_error)?
740                        .map_err(|error| stream_error(error.to_string()))?;
741                    if let Some(abort) = &mut prefill_abort {
742                        abort.disarm();
743                    }
744                }
745                StreamEvent::Decode(Some(Ok(bytes))) => yield bytes,
746                StreamEvent::Decode(Some(Err(error))) => {
747                    Err(stream_error(format!("decode stream failed: {error}")))?;
748                }
749                StreamEvent::Decode(None) => break,
750            }
751        }
752        if !prefill_done {
753            prefill_task
754                .await
755                .map_err(join_error)?
756                .map_err(|error| stream_error(error.to_string()))?;
757            if let Some(abort) = &mut prefill_abort {
758                abort.disarm();
759            }
760        }
761    }
762}
763
764enum StreamEvent<E> {
765    Prefill(std::result::Result<Result<(), ProxyHttpError>, tokio::task::JoinError>),
766    Decode(Option<std::result::Result<Bytes, E>>),
767}
768
769async fn next_stream_event<S, E>(
770    prefill_task: &mut JoinHandle<Result<(), ProxyHttpError>>,
771    decode_stream: &mut S,
772    prefill_done: bool,
773) -> StreamEvent<E>
774where
775    S: Stream<Item = std::result::Result<Bytes, E>> + Unpin,
776{
777    // Surface the prefill outcome promptly once the prefill task has COMPLETED, so
778    // a continuously-ready decode stream cannot defer (and thereby suppress) a
779    // prefill failure indefinitely. The caller delivers one already-ready decode
780    // chunk before propagating a prefill error (a one-time tie-break), so a chunk
781    // that was ready at the instant prefill finished is not dropped — but decode
782    // is NOT permanently prioritized.
783    if !prefill_done && prefill_task.is_finished() {
784        return StreamEvent::Prefill(prefill_task.await);
785    }
786    // Prefill is still running: deliver decode bytes as they arrive, and otherwise
787    // await the prefill task's completion (picked up by the `is_finished` check on
788    // the next call). An unbiased race is fine here — there is no completed prefill
789    // outcome to drop, and a ready decode chunk taken by its own branch is yielded,
790    // not lost.
791    tokio::select! {
792        prefill = prefill_task, if !prefill_done => StreamEvent::Prefill(prefill),
793        chunk = decode_stream.next() => StreamEvent::Decode(chunk),
794    }
795}
796
797fn join_error(error: tokio::task::JoinError) -> std::io::Error {
798    stream_error(format!("prefill task failed: {error}"))
799}
800
801fn stream_error(message: String) -> std::io::Error {
802    std::io::Error::other(message)
803}
804
805/// Aborts the held task when dropped unless [`disarm`](AbortOnDrop::disarm)ed.
806struct AbortOnDrop {
807    handle: tokio::task::AbortHandle,
808    armed: bool,
809}
810
811impl AbortOnDrop {
812    fn new(handle: tokio::task::AbortHandle) -> Self {
813        Self {
814            handle,
815            armed: true,
816        }
817    }
818
819    fn disarm(&mut self) {
820        self.armed = false;
821    }
822}
823
824impl Drop for AbortOnDrop {
825    fn drop(&mut self) {
826        if self.armed {
827            self.handle.abort();
828        }
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835    use anyhow::{Context, Result};
836
837    #[test]
838    fn join_path_normalizes_single_trailing_slash() {
839        assert_eq!(
840            join_path("http://h:1/", "/v1/models"),
841            "http://h:1/v1/models"
842        );
843        assert_eq!(
844            join_path("http://h:1", "/v1/models"),
845            "http://h:1/v1/models"
846        );
847    }
848
849    #[test]
850    fn status_code_maps_reqwest_status() -> Result<()> {
851        let mapped = status_code(reqwest::StatusCode::OK)
852            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
853        assert_eq!(mapped, StatusCode::OK);
854        Ok(())
855    }
856
857    #[test]
858    fn outbound_authorization_prefers_inbound_header() -> Result<()> {
859        let mut headers = HeaderMap::new();
860        headers.insert(header::AUTHORIZATION, "Bearer inbound".parse()?);
861        assert_eq!(
862            outbound_authorization(&headers),
863            Some("Bearer inbound".to_owned())
864        );
865        Ok(())
866    }
867
868    #[test]
869    fn proxy_error_internal_uses_500() {
870        let error = ProxyHttpError::internal("boom");
871        assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
872        assert_eq!(error.to_string(), "boom");
873    }
874
875    struct StaticPrimeTarget {
876        url: &'static str,
877        rank: u32,
878    }
879
880    impl PrimeFanoutTarget for StaticPrimeTarget {
881        fn url(&self) -> &str {
882            self.url
883        }
884
885        fn rank(&self) -> u32 {
886            self.rank
887        }
888    }
889
890    /// An empty prime fan-out must be a 502 with an explicit error: a 200
891    /// over zero targets would record a primed cache that primed nothing.
892    #[test]
893    fn prime_fanout_rejects_an_empty_target_set() -> Result<()> {
894        let runtime = proxy_test_runtime()?;
895        let response = runtime.block_on(run_prime_fanout(
896            "prefix cache conditioning",
897            Vec::<StaticPrimeTarget>::new(),
898            |_target| async { Ok::<u16, PrimeFlowFailure>(200) },
899        ));
900        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
901        let body = runtime.block_on(axum::body::to_bytes(response.into_body(), usize::MAX))?;
902        let value: Value = serde_json::from_slice(&body)?;
903        assert!(
904            value["error"]
905                .as_str()
906                .is_some_and(|error| error.contains("no targets")),
907            "got {value}"
908        );
909        Ok(())
910    }
911
912    /// Same guard for the reset/flush sweep: zero targets is a proxy
913    /// failure, never a clean sweep.
914    #[test]
915    fn sweep_fanout_rejects_an_empty_target_set() -> Result<()> {
916        let runtime = proxy_test_runtime()?;
917        let client = build_pooled_client().map_err(|error| anyhow::anyhow!(error.to_string()))?;
918        let response = runtime.block_on(run_sweep_fanout(
919            client,
920            "prefix cache reset",
921            "/reset_prefix_cache",
922            Vec::new(),
923            None,
924        ));
925        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
926        let body = runtime.block_on(axum::body::to_bytes(response.into_body(), usize::MAX))?;
927        let value: Value = serde_json::from_slice(&body)?;
928        assert!(
929            value["error"]
930                .as_str()
931                .is_some_and(|error| error.contains("no targets")),
932            "got {value}"
933        );
934        Ok(())
935    }
936
937    /// A hung target must not stall the remaining ranks: the flow is bounded
938    /// by the per-target timeout and surfaces as a failed target (206).
939    #[test]
940    fn prime_fanout_times_out_a_hung_target() -> Result<()> {
941        let runtime = proxy_test_runtime()?;
942        let response = runtime.block_on(run_prime_fanout_with_timeout(
943            "prefix cache conditioning",
944            vec![StaticPrimeTarget {
945                url: "http://127.0.0.1:1",
946                rank: 0,
947            }],
948            |_target| async {
949                futures_util::future::pending::<()>().await;
950                Ok::<u16, PrimeFlowFailure>(200)
951            },
952            Duration::from_millis(50),
953        ));
954        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
955        let body = runtime.block_on(axum::body::to_bytes(response.into_body(), usize::MAX))?;
956        let value: Value = serde_json::from_slice(&body)?;
957        assert_eq!(value["targets"][0]["http_status"], Value::Null);
958        assert!(
959            value["targets"][0]["error"]
960                .as_str()
961                .is_some_and(|error| error.contains("timed out")),
962            "got {value}"
963        );
964        Ok(())
965    }
966
967    use std::sync::Arc;
968    use std::sync::atomic::{AtomicBool, Ordering};
969
970    /// Flips a shared flag when dropped — used to observe that an aborted
971    /// prefill task is actually cancelled (its future is dropped).
972    struct SetOnDrop(Arc<AtomicBool>);
973
974    impl Drop for SetOnDrop {
975        fn drop(&mut self) {
976            self.0.store(true, Ordering::SeqCst);
977        }
978    }
979
980    fn proxy_test_runtime() -> Result<tokio::runtime::Runtime> {
981        tokio::runtime::Builder::new_multi_thread()
982            .enable_all()
983            .build()
984            .map_err(|error| anyhow::anyhow!(error.to_string()))
985    }
986
987    #[test]
988    fn streamed_decode_yields_bytes_in_order_when_prefill_succeeds() -> Result<()> {
989        let runtime = proxy_test_runtime()?;
990        let bytes = runtime.block_on(async {
991            let decode = Box::pin(futures_util::stream::iter(vec![
992                std::result::Result::<Bytes, std::io::Error>::Ok(Bytes::from_static(b"hello")),
993                Ok(Bytes::from_static(b" world")),
994            ]));
995            let prefill = tokio::spawn(async { Ok::<(), ProxyHttpError>(()) });
996            let mut stream = Box::pin(decode_response_stream(decode, prefill, OnClientDrop::Abort));
997            let mut out = Vec::new();
998            while let Some(item) = stream.next().await {
999                out.push(item.map_err(|error| anyhow::anyhow!(error.to_string()))?);
1000            }
1001            anyhow::Ok(out)
1002        })?;
1003        let joined: Vec<u8> = bytes.into_iter().flatten().collect();
1004        assert_eq!(joined, b"hello world");
1005        Ok(())
1006    }
1007
1008    #[test]
1009    fn streamed_decode_surfaces_prefill_error_after_decode_ends() -> Result<()> {
1010        let runtime = proxy_test_runtime()?;
1011        let (bytes, error) = runtime.block_on(async {
1012            let decode = Box::pin(futures_util::stream::iter(vec![std::result::Result::<
1013                Bytes,
1014                std::io::Error,
1015            >::Ok(
1016                Bytes::from_static(b"partial"),
1017            )]));
1018            let prefill = tokio::spawn(async {
1019                Err::<(), ProxyHttpError>(ProxyHttpError::internal("prefill boom"))
1020            });
1021            let mut stream = Box::pin(decode_response_stream(decode, prefill, OnClientDrop::Abort));
1022            let mut bytes = Vec::new();
1023            let mut error = None;
1024            while let Some(item) = stream.next().await {
1025                match item {
1026                    Ok(chunk) => bytes.extend_from_slice(&chunk),
1027                    Err(stream_error) => {
1028                        error = Some(stream_error.to_string());
1029                        break;
1030                    }
1031                }
1032            }
1033            anyhow::Ok((bytes, error))
1034        })?;
1035        assert_eq!(bytes, b"partial");
1036        let error = error.context("expected a prefill error to surface after decode ended")?;
1037        assert!(error.contains("prefill boom"), "got {error}");
1038        Ok(())
1039    }
1040
1041    /// A prefill failure must surface even while the decode stream stays
1042    /// continuously ready. The one-time tie-break delivers an already-ready chunk
1043    /// but must NOT let an always-ready decode stream defer the prefill error
1044    /// indefinitely (a permanent decode bias would suppress it).
1045    #[test]
1046    fn prefill_error_surfaces_even_while_decode_stays_ready() -> Result<()> {
1047        let runtime = proxy_test_runtime()?;
1048        let error = runtime.block_on(async {
1049            // An unbounded, always-synchronously-ready decode stream.
1050            let decode = Box::pin(futures_util::stream::repeat_with(|| {
1051                std::result::Result::<Bytes, std::io::Error>::Ok(Bytes::from_static(b"x"))
1052            }));
1053            let prefill = tokio::spawn(async {
1054                Err::<(), ProxyHttpError>(ProxyHttpError::internal("prefill boom"))
1055            });
1056            let mut stream = Box::pin(decode_response_stream(decode, prefill, OnClientDrop::Abort));
1057            let mut chunks = 0usize;
1058            let mut error = None;
1059            while let Some(item) = stream.next().await {
1060                match item {
1061                    Ok(_) => {
1062                        chunks += 1;
1063                        // Bound: prove the error is not suppressed forever. The fix
1064                        // surfaces it within a handful of chunks; a regression that
1065                        // permanently prefers decode would never break out here.
1066                        assert!(
1067                            chunks < 100_000,
1068                            "prefill error was suppressed by a continuously-ready decode stream"
1069                        );
1070                    }
1071                    Err(stream_error) => {
1072                        error = Some(stream_error.to_string());
1073                        break;
1074                    }
1075                }
1076            }
1077            anyhow::Ok(error)
1078        })?;
1079        let error = error.context("a prefill error must surface even while decode stays ready")?;
1080        assert!(error.contains("prefill boom"), "got {error}");
1081        Ok(())
1082    }
1083
1084    /// A decode error that is ALREADY ready at the instant prefill completes must be
1085    /// propagated by the one-time tie-break, not silently dropped. `now_or_never()`
1086    /// polls (and thus consumes) that ready item, so an Ok-only match would discard
1087    /// the error; with a successful prefill the stream would then end cleanly,
1088    /// turning a decode failure into a truncated 200.
1089    #[test]
1090    fn decode_error_ready_at_tiebreak_is_not_swallowed() -> Result<()> {
1091        let runtime = proxy_test_runtime()?;
1092        let error = runtime.block_on(async {
1093            // Force prefill to be FINISHED (Ok) so the first stream event is the
1094            // prefill outcome and the tie-break is what polls the decode stream.
1095            let prefill = tokio::spawn(async { Ok::<(), ProxyHttpError>(()) });
1096            while !prefill.is_finished() {
1097                tokio::task::yield_now().await;
1098            }
1099            // A synchronously-ready decode Err waiting at the tie-break instant.
1100            let decode = Box::pin(futures_util::stream::iter(vec![std::result::Result::<
1101                Bytes,
1102                std::io::Error,
1103            >::Err(
1104                std::io::Error::other("decode boom"),
1105            )]));
1106            let mut stream = Box::pin(decode_response_stream(decode, prefill, OnClientDrop::Abort));
1107            let mut error = None;
1108            while let Some(item) = stream.next().await {
1109                if let Err(stream_error) = item {
1110                    error = Some(stream_error.to_string());
1111                    break;
1112                }
1113            }
1114            anyhow::Ok(error)
1115        })?;
1116        let error =
1117            error.context("a decode error ready at the tie-break must surface, not truncate")?;
1118        assert!(error.contains("decode boom"), "got {error}");
1119        Ok(())
1120    }
1121
1122    #[test]
1123    fn dropping_the_stream_before_prefill_finishes_aborts_prefill() -> Result<()> {
1124        let runtime = proxy_test_runtime()?;
1125        let aborted = Arc::new(AtomicBool::new(false));
1126        let flag = aborted.clone();
1127        let cancelled = runtime.block_on(async move {
1128            let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1129            // Prefill never completes; once aborted, its future is dropped and
1130            // SetOnDrop flips the flag. It signals `started` only AFTER the guard
1131            // is constructed, so the drop-abort is observed deterministically (no
1132            // race on whether the task was polled before the abort fired).
1133            let prefill = tokio::spawn(async move {
1134                let _guard = SetOnDrop(flag);
1135                let _ = started_tx.send(());
1136                futures_util::future::pending::<()>().await;
1137                Ok::<(), ProxyHttpError>(())
1138            });
1139            let _ = started_rx.await;
1140            // Decode yields one chunk then stays pending, so the loop neither
1141            // breaks (decode EOF) nor selects prefill — leaving prefill in flight.
1142            let decode = Box::pin(
1143                futures_util::stream::once(async {
1144                    std::result::Result::<Bytes, std::io::Error>::Ok(Bytes::from_static(b"a"))
1145                })
1146                .chain(futures_util::stream::pending::<
1147                    std::result::Result<Bytes, std::io::Error>,
1148                >()),
1149            );
1150            let mut stream = Box::pin(decode_response_stream(decode, prefill, OnClientDrop::Abort));
1151            assert!(matches!(stream.next().await, Some(Ok(_))));
1152            drop(stream);
1153            for _ in 0..200 {
1154                if aborted.load(Ordering::SeqCst) {
1155                    return true;
1156                }
1157                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1158            }
1159            false
1160        });
1161        assert!(
1162            cancelled,
1163            "prefill task was not aborted when the response stream was dropped"
1164        );
1165        Ok(())
1166    }
1167
1168    /// The counterpart pin for `OnClientDrop::Detach`: dropping the consumer
1169    /// must NOT abort the prefill task — it keeps running in the background
1170    /// and completes on its own.
1171    #[test]
1172    fn dropping_the_stream_before_prefill_finishes_detaches_prefill() -> Result<()> {
1173        let runtime = proxy_test_runtime()?;
1174        let completed = Arc::new(AtomicBool::new(false));
1175        let flag = completed.clone();
1176        let finished = runtime.block_on(async move {
1177            let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1178            // Prefill completes after a short delay and flips the flag; if the
1179            // stream drop aborted it, the flag would never be set.
1180            let prefill = tokio::spawn(async move {
1181                let _ = started_tx.send(());
1182                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1183                flag.store(true, Ordering::SeqCst);
1184                Ok::<(), ProxyHttpError>(())
1185            });
1186            let _ = started_rx.await;
1187            // Decode yields one chunk then stays pending, so prefill is still in
1188            // flight when the stream is dropped.
1189            let decode = Box::pin(
1190                futures_util::stream::once(async {
1191                    std::result::Result::<Bytes, std::io::Error>::Ok(Bytes::from_static(b"a"))
1192                })
1193                .chain(futures_util::stream::pending::<
1194                    std::result::Result<Bytes, std::io::Error>,
1195                >()),
1196            );
1197            let mut stream = Box::pin(decode_response_stream(
1198                decode,
1199                prefill,
1200                OnClientDrop::Detach,
1201            ));
1202            assert!(matches!(stream.next().await, Some(Ok(_))));
1203            drop(stream);
1204            for _ in 0..200 {
1205                if completed.load(Ordering::SeqCst) {
1206                    return true;
1207                }
1208                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1209            }
1210            false
1211        });
1212        assert!(
1213            finished,
1214            "prefill task was aborted instead of detached when the response stream was dropped"
1215        );
1216        Ok(())
1217    }
1218}