Files
sglang/experimental/sgl-router/src/proxy/mod.rs
T

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()
));
}
}