Co-authored-by: Kangyan Zhou <kangyan.zhou@radixark.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
341 lines
14 KiB
Rust
341 lines
14 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! HTTP proxy — forwards requests to the upstream SGLang worker.
|
|
|
|
pub mod sse;
|
|
|
|
use crate::health::circuit_breaker::CircuitBreaker;
|
|
use crate::server::error::ApiError;
|
|
use crate::server::header_utils::should_forward_request_header;
|
|
use crate::workers::WireProtocol;
|
|
use anyhow::Context;
|
|
use axum::body::Body;
|
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response};
|
|
use bytes::Bytes;
|
|
use reqwest::{Client, Url};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
/// Parse a worker URL emitted by discovery. On failure, trip the worker's
|
|
/// circuit breaker so the malformed worker drops out of subsequent
|
|
/// `healthy_workers_for(...)` selection, then surface the error as
|
|
/// `ApiError::WorkerMisconfigured`.
|
|
fn parse_worker_url(worker_url: &str, breaker: &CircuitBreaker) -> Result<Url, ApiError> {
|
|
Url::parse(worker_url).map_err(|e| {
|
|
breaker.record_failure();
|
|
ApiError::WorkerMisconfigured {
|
|
worker: worker_url.to_string(),
|
|
source: anyhow::Error::new(e).context("parse worker URL"),
|
|
}
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Proxy {
|
|
/// The negotiating client: HTTP/1.1 in cleartext, and ALPN `h2, http/1.1`
|
|
/// over TLS. Safe against any engine, which is why it is also the client
|
|
/// for side-channel admin traffic (`/flush_cache`).
|
|
default_client: Client,
|
|
/// Cleartext h2c (HTTP/2 prior knowledge). No negotiation happens, so this
|
|
/// is used only for workers whose `/server_info` reported `--enable-http2`
|
|
/// on a cleartext URL.
|
|
h2c_client: Client,
|
|
/// Wall-clock timeout applied to non-streaming upstream requests. Streaming
|
|
/// requests deliberately do not use this (long generations are valid).
|
|
pub request_timeout: Duration,
|
|
}
|
|
|
|
/// Build a forwarding client for `protocol`, sharing pool/connect tuning
|
|
/// across protocols. The h2c variant pins HTTP/2 prior knowledge, which is
|
|
/// what Granian's `HTTPModes.auto` serves on a plaintext port; plaintext has
|
|
/// no ALPN, so prior knowledge is the only way to reach it.
|
|
fn build_client(protocol: WireProtocol) -> Result<Client, anyhow::Error> {
|
|
let builder = Client::builder()
|
|
.pool_max_idle_per_host(64)
|
|
.connect_timeout(Duration::from_secs(5));
|
|
match protocol {
|
|
WireProtocol::Http1 => builder,
|
|
WireProtocol::H2c => builder.http2_prior_knowledge(),
|
|
}
|
|
.build()
|
|
.context("build reqwest client")
|
|
}
|
|
|
|
impl Proxy {
|
|
/// Build a proxy. `request_timeout` is the per-request wall-clock budget for
|
|
/// non-streaming forwards. Connect timeout is hard-coded to 5 s — even a
|
|
/// streaming request fails fast at TCP setup if the worker is unreachable.
|
|
///
|
|
/// WHY both clients up front: protocol is a per-worker property resolved
|
|
/// from each engine's `/server_info`, so the request path must be able to
|
|
/// pick either one per request. Building them here reduces that to a
|
|
/// selection — no per-request client construction, and no single shared
|
|
/// client whose first writer decides the protocol for the whole fleet.
|
|
pub fn new(request_timeout: Duration) -> Result<Self, anyhow::Error> {
|
|
Ok(Self {
|
|
default_client: build_client(WireProtocol::Http1)?,
|
|
h2c_client: build_client(WireProtocol::H2c)?,
|
|
request_timeout,
|
|
})
|
|
}
|
|
|
|
/// The forwarding client for `protocol`, taken from the selected worker's
|
|
/// [`crate::workers::Worker::protocol`].
|
|
fn client_for(&self, protocol: WireProtocol) -> &Client {
|
|
match protocol {
|
|
WireProtocol::Http1 => &self.default_client,
|
|
WireProtocol::H2c => &self.h2c_client,
|
|
}
|
|
}
|
|
|
|
/// The client for side-channel admin traffic (e.g. `/flush_cache`), which
|
|
/// fans out across workers and so cannot use any one worker's protocol.
|
|
pub fn admin_client(&self) -> &Client {
|
|
&self.default_client
|
|
}
|
|
|
|
/// Classify a reqwest error into the right `ApiError` variant, given an
|
|
/// explicit worker URL. Called from the breaker-gated `forward_*_to`
|
|
/// methods, which carry per-request worker URLs (not a single proxy-level
|
|
/// URL).
|
|
///
|
|
/// Walks the full source chain to detect timeouts, because reqwest wraps
|
|
/// hyper which wraps `std::io::Error` — a top-level `is_timeout()` check
|
|
/// misses both the wrapped reqwest timeout and the `io::ErrorKind::TimedOut`
|
|
/// cases.
|
|
fn classify_reqwest_error_for(worker: Url, e: reqwest::Error, path: &str) -> ApiError {
|
|
let source = anyhow::Error::new(e).context(format!("worker {worker}: post {path}"));
|
|
let is_timeout = source.chain().any(|c| {
|
|
c.downcast_ref::<reqwest::Error>()
|
|
.is_some_and(|r| r.is_timeout())
|
|
}) || source.chain().any(|c| {
|
|
c.downcast_ref::<std::io::Error>()
|
|
.is_some_and(|io| io.kind() == std::io::ErrorKind::TimedOut)
|
|
});
|
|
if is_timeout {
|
|
ApiError::UpstreamTimeout { worker }
|
|
} else {
|
|
ApiError::UpstreamUnreachable { worker, source }
|
|
}
|
|
}
|
|
|
|
/// Breaker-gated JSON POST: checks `breaker.allow()` first, records
|
|
/// success/failure based on response status, and returns
|
|
/// `ApiError::BreakerOpen` immediately when the breaker is Open.
|
|
///
|
|
/// `worker_url` is the discovery-emitted worker URL string. It's parsed
|
|
/// to [`reqwest::Url`] internally so we can use [`Url::join`] for clean
|
|
/// path concatenation (no double-slash) and pass a typed URL to the
|
|
/// split error variants (`UpstreamUnreachable` / `UpstreamTimeout` /
|
|
/// `UpstreamStatus`).
|
|
pub async fn forward_json_to(
|
|
&self,
|
|
worker_url: &str,
|
|
protocol: WireProtocol,
|
|
breaker: &CircuitBreaker,
|
|
path: &str,
|
|
headers: &HeaderMap,
|
|
body: Bytes,
|
|
) -> Result<Response<Body>, ApiError> {
|
|
if !breaker.allow() {
|
|
return Err(ApiError::BreakerOpen {
|
|
worker: worker_url.to_string(),
|
|
});
|
|
}
|
|
let worker_url = parse_worker_url(worker_url, breaker)?;
|
|
let url = worker_url.join(path).map_err(|e| {
|
|
ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}")))
|
|
})?;
|
|
let mut req = self.client_for(protocol).post(url.clone()).body(body);
|
|
for (k, v) in headers {
|
|
if should_forward_request_header(k) {
|
|
req = req.header(k, v);
|
|
}
|
|
}
|
|
req = req
|
|
.header("content-type", "application/json")
|
|
.timeout(self.request_timeout);
|
|
let resp = req.send().await.map_err(|e| {
|
|
breaker.record_failure();
|
|
Self::classify_reqwest_error_for(worker_url.clone(), e, path)
|
|
})?;
|
|
let status = resp.status();
|
|
// Defer breaker recording until after the body completes — a
|
|
// worker that returns 2xx headers and then drops mid-body is
|
|
// still failing the request, and crediting it as healthy lets
|
|
// a misbehaving worker stay eligible. For 5xx the early bail is
|
|
// safe (no body to consume meaningfully), but we still wait
|
|
// until after the read attempt to record exactly once.
|
|
let bytes = match resp.bytes().await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
upstream = %url,
|
|
status = %status,
|
|
error = ?e,
|
|
"upstream dropped connection mid-body",
|
|
);
|
|
breaker.record_failure();
|
|
return Err(ApiError::UpstreamStatus { status });
|
|
}
|
|
};
|
|
if status.is_server_error() {
|
|
breaker.record_failure();
|
|
} else {
|
|
breaker.record_success();
|
|
}
|
|
let mut out = Response::new(Body::from(bytes));
|
|
*out.status_mut() = status;
|
|
out.headers_mut().insert(
|
|
HeaderName::from_static("content-type"),
|
|
HeaderValue::from_static("application/json"),
|
|
);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Breaker-gated streaming POST: checks `breaker.allow()` first, records
|
|
/// success/failure, and returns `ApiError::BreakerOpen` when Open.
|
|
///
|
|
/// `stream_guards` — when `Some`, the value is threaded into the SSE
|
|
/// pump task and held for the entire body lifetime (headers → last byte
|
|
/// / client disconnect). The proxy does not inspect the boxed value; it
|
|
/// relies entirely on `Drop` semantics, so callers typically pack
|
|
/// `(LoadGuard, ActiveLoadGuard)` here. This keeps both the per-worker
|
|
/// `active_requests` counter and the per-request active-load entry alive
|
|
/// for the full streaming lifetime — without which a long-running SSE
|
|
/// response would under-report load.
|
|
// Each parameter is a distinct, required input to a single upstream
|
|
// forward (target, protocol, breaker, path, headers, body, plus the
|
|
// streaming-lifetime callbacks). Bundling them into a struct purely to
|
|
// satisfy the arg-count heuristic would add indirection without clarity.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn forward_streaming_to(
|
|
&self,
|
|
worker_url: &str,
|
|
protocol: WireProtocol,
|
|
breaker: &Arc<CircuitBreaker>,
|
|
path: &str,
|
|
headers: &HeaderMap,
|
|
body: Bytes,
|
|
stream_guards: Option<Box<dyn Send + 'static>>,
|
|
on_first_byte: Option<Box<dyn FnOnce() + Send + 'static>>,
|
|
on_stream_end: Option<Box<dyn FnOnce(sse::StreamEnd) + Send + 'static>>,
|
|
) -> Result<Response<Body>, ApiError> {
|
|
if !breaker.allow() {
|
|
return Err(ApiError::BreakerOpen {
|
|
worker: worker_url.to_string(),
|
|
});
|
|
}
|
|
let worker_url = parse_worker_url(worker_url, breaker)?;
|
|
let url = worker_url.join(path).map_err(|e| {
|
|
ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}")))
|
|
})?;
|
|
let mut req = self.client_for(protocol).post(url.clone()).body(body);
|
|
for (k, v) in headers {
|
|
if should_forward_request_header(k) {
|
|
req = req.header(k, v);
|
|
}
|
|
}
|
|
req = req
|
|
.header("content-type", "application/json")
|
|
.header("accept", "text/event-stream");
|
|
let resp = req.send().await.map_err(|e| {
|
|
breaker.record_failure();
|
|
Self::classify_reqwest_error_for(worker_url.clone(), e, path)
|
|
})?;
|
|
let status = resp.status();
|
|
let upstream_ct = resp
|
|
.headers()
|
|
.get(reqwest::header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("application/json")
|
|
.to_string();
|
|
let content_type = if status.is_success() {
|
|
"text/event-stream".to_string()
|
|
} else {
|
|
upstream_ct
|
|
};
|
|
// Breaker recording is deferred to the pump's completion hook so
|
|
// an upstream that returns 2xx headers and then drops mid-stream
|
|
// is recorded as a failure. For 5xx headers we record_failure
|
|
// up front and skip the pump hook (the body we surface is the
|
|
// error response — its stream completing is not a worker win).
|
|
let caller_end_hook = if status.is_success() {
|
|
on_stream_end
|
|
} else {
|
|
None
|
|
};
|
|
let on_complete: Option<Box<dyn FnOnce(sse::StreamEnd) + Send + 'static>> =
|
|
if status.is_server_error() {
|
|
breaker.record_failure();
|
|
None
|
|
} else {
|
|
let breaker_for_hook = Arc::clone(breaker);
|
|
Some(Box::new(move |end| {
|
|
if end.transport_ok {
|
|
breaker_for_hook.record_success();
|
|
} else {
|
|
breaker_for_hook.record_failure();
|
|
}
|
|
if let Some(hook) = caller_end_hook {
|
|
hook(end);
|
|
}
|
|
}))
|
|
};
|
|
// Only record TTFT for successful streams; error-body chunks are not
|
|
// generated tokens.
|
|
let first_byte_hook = if status.is_success() {
|
|
on_first_byte
|
|
} else {
|
|
None
|
|
};
|
|
let body = sse::bytes_stream_to_body(
|
|
resp.bytes_stream(),
|
|
stream_guards,
|
|
on_complete,
|
|
first_byte_hook,
|
|
);
|
|
let mut out = Response::new(body);
|
|
*out.status_mut() = status;
|
|
out.headers_mut().insert(
|
|
HeaderName::from_static("content-type"),
|
|
HeaderValue::from_str(&content_type)
|
|
.unwrap_or_else(|_| HeaderValue::from_static("application/json")),
|
|
);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
#[tokio::test]
|
|
async fn new_returns_result_not_panic() {
|
|
let p = Proxy::new(Duration::from_secs(5)).unwrap();
|
|
assert_eq!(p.request_timeout, Duration::from_secs(5));
|
|
}
|
|
|
|
/// `client_for` routes each protocol to its own field, and admin traffic
|
|
/// shares the default client. Asserting the two clients differ by address
|
|
/// would be vacuous — they are distinct struct fields, so that holds even
|
|
/// if `build_client` ignored its argument. What the selector must get right
|
|
/// is the mapping, so pin that instead; the on-the-wire difference between
|
|
/// the two clients is covered by tests/proxy/h2c_forward.rs.
|
|
#[tokio::test]
|
|
async fn client_for_maps_each_protocol_to_its_own_client() {
|
|
let p = Proxy::new(Duration::from_secs(5)).unwrap();
|
|
assert!(std::ptr::eq(
|
|
p.client_for(WireProtocol::Http1),
|
|
&p.default_client
|
|
));
|
|
assert!(std::ptr::eq(p.client_for(WireProtocol::H2c), &p.h2c_client));
|
|
assert!(std::ptr::eq(
|
|
p.client_for(WireProtocol::Http1),
|
|
p.admin_client()
|
|
));
|
|
}
|
|
}
|