[router] Speak cleartext h2c on both edges: serve it inbound, forward it outbound (#39006)

Co-authored-by: Kangyan Zhou <kangyan.zhou@radixark.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kangyan-Zhou
2026-09-15 22:30:31 -07:00
committed by GitHub
co-authored by Kangyan Zhou Claude Opus 5
parent c1f5b4736a
commit afde31a2f5
17 changed files with 1009 additions and 41 deletions
+3
View File
@@ -2887,6 +2887,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -3294,6 +3295,8 @@ dependencies = [
"futures", "futures",
"hf-hub", "hf-hub",
"http-body-util", "http-body-util",
"hyper",
"hyper-util",
"k8s-openapi", "k8s-openapi",
"kube", "kube",
"minijinja", "minijinja",
+21 -2
View File
@@ -38,10 +38,21 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
# Async runtime + http # Async runtime + http
tokio = { version = "1.42", features = ["full"] } tokio = { version = "1.42", features = ["full"] }
axum = { version = "0.8", features = ["macros", "tracing"] } # http2 on both sides: axum serves cleartext h2c on the inbound listener, reqwest
# dials it outbound.
#
# axum's `http2` looks redundant and is not — please don't drop it. `axum::serve`
# runs on hyper-util's auto Builder, whose HTTP/2 branch is compiled by
# `hyper-util/http2`, and that arrives today only transitively, via
# sgl-kv-indexer -> tonic "transport" -> tonic/server -> hyper-util/server-auto
# (which is server + http1 + http2). So the listener speaks h2c whether or not
# this line exists. Declaring it forwards `hyper-util/http2` from axum directly,
# so inbound h2c no longer rides on a gRPC dependency it has nothing to do with,
# and it enables HTTP/2 extended CONNECT for h2 websockets.
axum = { version = "0.8", features = ["macros", "tracing", "http2"] }
tower = { version = "0.5", features = ["full"] } tower = { version = "0.5", features = ["full"] }
tower-http = { version = "0.6", features = ["trace", "compression-gzip", "cors", "timeout", "request-id"] } tower-http = { version = "0.6", features = ["trace", "compression-gzip", "cors", "timeout", "request-id"] }
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"], default-features = false } reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls", "http2"], default-features = false }
sgl-kv-indexer = { path = "sgl-kv-indexer" } sgl-kv-indexer = { path = "sgl-kv-indexer" }
# Serialization # Serialization
@@ -81,6 +92,14 @@ zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime
[dev-dependencies] [dev-dependencies]
dirs = "5" dirs = "5"
http-body-util = "0.1" http-body-util = "0.1"
# HTTP/2-only mock server for the proxy h2c forwarding tests (tests/proxy/h2c_forward.rs).
# `http1` as well as `http2`: tests/proxy/pd_protocol_binding.rs drives both
# `hyper::server::conn::http1` and `::http2` to build single-protocol mocks.
# `http1` arrives transitively via axum's defaults today; declaring it here
# means an axum `default-features = false` later fails at the dependency
# rather than as an unresolved import in a test.
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["tokio"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tempfile = "3" tempfile = "3"
+44 -1
View File
@@ -6,7 +6,8 @@ Serves a single model and routes across its workers. Exposes
`/v1/tokenize`, `/v1/detokenize`, `/v1/models`, `/v1/chat/completions` `/v1/tokenize`, `/v1/detokenize`, `/v1/models`, `/v1/chat/completions`
(buffered and SSE), plus `/healthz` / `/readyz` and `/metrics`. Worker (buffered and SSE), plus `/healthz` / `/readyz` and `/metrics`. Worker
pools come from either a static URL list or Kubernetes EndpointSlice pools come from either a static URL list or Kubernetes EndpointSlice
discovery. discovery. Both edges speak cleartext HTTP/2 where the peer does — see
[HTTP/2](#http2).
## Building ## Building
@@ -68,6 +69,48 @@ The Indexer replaces the Router-local radix tree as the native Cache-Aware
signal. Query timeouts and local concurrency are bounded by the two Indexer signal. Query timeouts and local concurrency are bounded by the two Indexer
options, which default to 100 ms and 32 respectively. options, which default to 100 ms and 32 respectively.
## HTTP/2
There is nothing to configure. The router negotiates per connection inbound and
resolves the protocol per worker outbound; every combination below is reached
automatically, and HTTP/1.1 remains a supported peer on both edges.
**Inbound.** The listener accepts cleartext HTTP/2 (h2c, prior knowledge) and
HTTP/1.1 on the same `--port`, chosen per connection. A mesh sidecar or
load balancer that prefers h2c multiplexes over one connection instead of
opening one per request; an HTTP/1.1 client is unaffected.
**Outbound.** At registration the router reads each worker's `/server_info` and
forwards over cleartext h2c only when that worker reports `--enable-http2` (the
engine's Granian server, which serves h2c and HTTP/1.1 together) **and** the
worker URL is cleartext. Everything else uses the default client, which speaks
HTTP/1.1 in cleartext and negotiates ALPN `h2, http/1.1` over TLS:
| `/server_info` | worker URL | router forwards over |
|---|---|---|
| `enable_http2: true` | `http://` | cleartext h2c |
| `enable_http2: true` | `https://` | HTTP/2 over TLS, by ALPN |
| `enable_http2: false`, or absent | any | HTTP/1.1 (cleartext) or ALPN (TLS) |
The choice is per worker, not fleet-wide, so a mixed fleet works and one
worker's state never changes another's. The flag comes from the `/server_info`
launch record, which has reported it all along, so no engine change is needed.
A worker whose `/server_info` does not answer has no readable protocol and
forwards over HTTP/1.1 for as long as it stays registered — a throughput cost,
never a correctness one. Admin fan-out (`/flush_cache`) always uses the default
client, because it addresses every worker at once rather than a selected one.
Two things worth knowing when debugging. h2c is prior-knowledge only — there is
no negotiation and no fallback — which is why the router requires the engine's
own `enable_http2` report before using it. And a worker's protocol is fixed
for as long as that worker stays registered: it is read once, at registration,
and never re-read. Under the K8s backend a restarting engine flips its
EndpointSlice to `ready=false`, which is a `Removed` → `Added` cycle and so a
fresh reading; under `--worker-urls` the fan-out happens once at startup and
nothing re-registers. So a worker registered over h2c that later stops serving
it (a proxy interposed on its port, say) is not detected until it
is re-registered; its circuit breaker will open in the meantime.
## Upgrading from `cache_aware_zmq` ## Upgrading from `cache_aware_zmq`
The `cache_aware_zmq` policy has been removed. Configurations using it should The `cache_aware_zmq` policy has been removed. Configurations using it should
+4
View File
@@ -167,6 +167,10 @@ async fn main() -> Result<()> {
sgl_router::policies::active_load::spawn_janitor(Arc::clone(&active_load), sweep_interval); sgl_router::policies::active_load::spawn_janitor(Arc::clone(&active_load), sweep_interval);
// Spawn discovery + manager tasks. // Spawn discovery + manager tasks.
// The manager resolves each worker's wire protocol from its `/server_info`
// and stamps it onto the registered worker. The proxy holds one client per
// protocol and selects by the worker's protocol per request, so the manager
// needs no proxy handle.
let (event_rx, discovery_handle) = sgl_router::discovery::spawn_discovery(&cfg) let (event_rx, discovery_handle) = sgl_router::discovery::spawn_discovery(&cfg)
.await .await
.context("spawn discovery")?; .context("spawn discovery")?;
+73 -10
View File
@@ -8,6 +8,7 @@ pub mod sse;
use crate::health::circuit_breaker::CircuitBreaker; use crate::health::circuit_breaker::CircuitBreaker;
use crate::server::error::ApiError; use crate::server::error::ApiError;
use crate::server::header_utils::should_forward_request_header; use crate::server::header_utils::should_forward_request_header;
use crate::workers::WireProtocol;
use anyhow::Context; use anyhow::Context;
use axum::body::Body; use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response}; use axum::http::{HeaderMap, HeaderName, HeaderValue, Response};
@@ -32,28 +33,68 @@ fn parse_worker_url(worker_url: &str, breaker: &CircuitBreaker) -> Result<Url, A
#[derive(Debug)] #[derive(Debug)]
pub struct Proxy { pub struct Proxy {
pub client: Client, /// 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 /// Wall-clock timeout applied to non-streaming upstream requests. Streaming
/// requests deliberately do not use this (long generations are valid). /// requests deliberately do not use this (long generations are valid).
pub request_timeout: Duration, 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 { impl Proxy {
/// Build a proxy. `request_timeout` is the per-request wall-clock budget for /// 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 /// 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. /// 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> { pub fn new(request_timeout: Duration) -> Result<Self, anyhow::Error> {
let client = Client::builder()
.pool_max_idle_per_host(64)
.connect_timeout(Duration::from_secs(5))
.build()
.context("build reqwest client")?;
Ok(Self { Ok(Self {
client, default_client: build_client(WireProtocol::Http1)?,
h2c_client: build_client(WireProtocol::H2c)?,
request_timeout, 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 /// Classify a reqwest error into the right `ApiError` variant, given an
/// explicit worker URL. Called from the breaker-gated `forward_*_to` /// explicit worker URL. Called from the breaker-gated `forward_*_to`
/// methods, which carry per-request worker URLs (not a single proxy-level /// methods, which carry per-request worker URLs (not a single proxy-level
@@ -91,6 +132,7 @@ impl Proxy {
pub async fn forward_json_to( pub async fn forward_json_to(
&self, &self,
worker_url: &str, worker_url: &str,
protocol: WireProtocol,
breaker: &CircuitBreaker, breaker: &CircuitBreaker,
path: &str, path: &str,
headers: &HeaderMap, headers: &HeaderMap,
@@ -105,7 +147,7 @@ impl Proxy {
let url = worker_url.join(path).map_err(|e| { let url = worker_url.join(path).map_err(|e| {
ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}"))) ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}")))
})?; })?;
let mut req = self.client.post(url.clone()).body(body); let mut req = self.client_for(protocol).post(url.clone()).body(body);
for (k, v) in headers { for (k, v) in headers {
if should_forward_request_header(k) { if should_forward_request_header(k) {
req = req.header(k, v); req = req.header(k, v);
@@ -164,13 +206,14 @@ impl Proxy {
/// for the full streaming lifetime — without which a long-running SSE /// for the full streaming lifetime — without which a long-running SSE
/// response would under-report load. /// response would under-report load.
// Each parameter is a distinct, required input to a single upstream // Each parameter is a distinct, required input to a single upstream
// forward (target, breaker, path, headers, body, plus the // forward (target, protocol, breaker, path, headers, body, plus the
// streaming-lifetime callbacks). Bundling them into a struct purely to // streaming-lifetime callbacks). Bundling them into a struct purely to
// satisfy the arg-count heuristic would add indirection without clarity. // satisfy the arg-count heuristic would add indirection without clarity.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn forward_streaming_to( pub async fn forward_streaming_to(
&self, &self,
worker_url: &str, worker_url: &str,
protocol: WireProtocol,
breaker: &Arc<CircuitBreaker>, breaker: &Arc<CircuitBreaker>,
path: &str, path: &str,
headers: &HeaderMap, headers: &HeaderMap,
@@ -188,7 +231,7 @@ impl Proxy {
let url = worker_url.join(path).map_err(|e| { let url = worker_url.join(path).map_err(|e| {
ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}"))) ApiError::Internal(anyhow::Error::new(e).context(format!("join worker path {path}")))
})?; })?;
let mut req = self.client.post(url.clone()).body(body); let mut req = self.client_for(protocol).post(url.clone()).body(body);
for (k, v) in headers { for (k, v) in headers {
if should_forward_request_header(k) { if should_forward_request_header(k) {
req = req.header(k, v); req = req.header(k, v);
@@ -274,4 +317,24 @@ mod tests {
let p = Proxy::new(Duration::from_secs(5)).unwrap(); let p = Proxy::new(Duration::from_secs(5)).unwrap();
assert_eq!(p.request_timeout, Duration::from_secs(5)); 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()
));
}
} }
@@ -100,8 +100,12 @@ pub async fn flush_cache(State(ctx): State<Arc<AppContext>>) -> Response {
.into_response(); .into_response();
} }
let (successful, failed) = let (successful, failed) = fan_out_flush(
fan_out_flush(&workers, &ctx.proxy.client, ctx.proxy.request_timeout).await; &workers,
ctx.proxy.admin_client(),
ctx.proxy.request_timeout,
)
.await;
// Partial failure is an operational event an operator needs to see at the // Partial failure is an operational event an operator needs to see at the
// common production log level — match the rest of the router, which warns // common production log level — match the rest of the router, which warns
@@ -539,6 +539,7 @@ pub async fn chat_completions(
let bootstrap_room = bootstrap_room.expect("PD dispatch implies a resolved bootstrap room"); let bootstrap_room = bootstrap_room.expect("PD dispatch implies a resolved bootstrap room");
let prefill_url = worker.url.clone(); let prefill_url = worker.url.clone();
let prefill_protocol = worker.protocol();
let prefill_breaker = Arc::clone(&worker.breaker); let prefill_breaker = Arc::clone(&worker.breaker);
let prefill_headers = headers.clone(); let prefill_headers = headers.clone();
let prefill_body = outgoing_body.clone(); let prefill_body = outgoing_body.clone();
@@ -555,6 +556,7 @@ pub async fn chat_completions(
match prefill_proxy match prefill_proxy
.forward_json_to( .forward_json_to(
&prefill_url, &prefill_url,
prefill_protocol,
&prefill_breaker, &prefill_breaker,
"/v1/chat/completions", "/v1/chat/completions",
&prefill_headers, &prefill_headers,
@@ -588,6 +590,7 @@ pub async fn chat_completions(
Box::new((decode_guard, decode_active_guard, make_duration_guard())); Box::new((decode_guard, decode_active_guard, make_duration_guard()));
let fetch = ctx.proxy.forward_streaming_to( let fetch = ctx.proxy.forward_streaming_to(
&decode_worker.url, &decode_worker.url,
decode_worker.protocol(),
&decode_worker.breaker, &decode_worker.breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -605,6 +608,7 @@ pub async fn chat_completions(
let _decode_hold = (decode_guard, decode_active_guard); let _decode_hold = (decode_guard, decode_active_guard);
let fetch = ctx.proxy.forward_json_to( let fetch = ctx.proxy.forward_json_to(
&decode_worker.url, &decode_worker.url,
decode_worker.protocol(),
&decode_worker.breaker, &decode_worker.breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -624,6 +628,7 @@ pub async fn chat_completions(
Box::new((guard, active_guard, make_duration_guard())); Box::new((guard, active_guard, make_duration_guard()));
let fetch = ctx.proxy.forward_streaming_to( let fetch = ctx.proxy.forward_streaming_to(
&worker.url, &worker.url,
worker.protocol(),
&worker.breaker, &worker.breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -653,6 +658,7 @@ pub async fn chat_completions(
let _holds: (LoadGuard, _) = (guard, active_guard); let _holds: (LoadGuard, _) = (guard, active_guard);
let fetch = ctx.proxy.forward_json_to( let fetch = ctx.proxy.forward_json_to(
&worker.url, &worker.url,
worker.protocol(),
&worker.breaker, &worker.breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
+11 -10
View File
@@ -82,11 +82,11 @@ fn dials_cleartext(worker_url: &str) -> Option<bool> {
/// that risk. /// that risk.
/// ///
/// [`WireProtocol::Http1`] is the fallback for everything else, and it does /// [`WireProtocol::Http1`] is the fallback for everything else, and it does
/// mean HTTP/1.1 on the wire: reqwest is built here without its `http2` /// not mean "HTTP/1.1 on the wire". It selects the negotiating client, which
/// feature, so the forwarding client advertises only `http/1.1` even on TLS. /// advertises ALPN `h2, http/1.1`, so an `https://` worker running
/// An `https://` worker running `--enable-http2` therefore stays on HTTP/1.1 /// `--enable-http2` reaches HTTP/2 over TLS on its own — the correct outcome,
/// — correct, because h2c cannot be sent to a TLS endpoint either way, but /// arrived at by negotiation rather than by assumption. Only cleartext workers
/// not the ALPN upgrade the flag might suggest. See [`WireProtocol`]. /// need prior-knowledge h2c. See [`WireProtocol`].
fn resolve_protocol(enable_http2: Option<bool>, cleartext: Option<bool>) -> WireProtocol { fn resolve_protocol(enable_http2: Option<bool>, cleartext: Option<bool>) -> WireProtocol {
match (enable_http2, cleartext) { match (enable_http2, cleartext) {
(Some(true), Some(true)) => WireProtocol::H2c, (Some(true), Some(true)) => WireProtocol::H2c,
@@ -449,12 +449,12 @@ fn log_protocol_resolution(worker_url: &str, enable_http2: Option<bool>, clearte
worker_url = %worker_url, worker_url = %worker_url,
"/server_info reports --enable-http2 on a cleartext worker; forwarding over h2c", "/server_info reports --enable-http2 on a cleartext worker; forwarding over h2c",
), ),
// A TLS worker. h2c is unsendable there and the client does not // A TLS worker. h2c is unsendable there, but the negotiating client
// negotiate, so this worker stays on HTTP/1.1. // advertises ALPN h2, so a TLS engine still reaches HTTP/2 on its own.
(Some(true), Some(false)) => tracing::info!( (Some(true), Some(false)) => tracing::info!(
worker_url = %worker_url, worker_url = %worker_url,
"/server_info reports --enable-http2 on a TLS worker; \ "/server_info reports --enable-http2 on a TLS worker; using the \
forwarding over HTTP/1.1", negotiating client, which reaches HTTP/2 over TLS via ALPN",
), ),
// `dials_cleartext` could not parse the URL. Reaching this at all means // `dials_cleartext` could not parse the URL. Reaching this at all means
// `/server_info` answered over a URL the scheme check then rejected, so // `/server_info` answered over a URL the scheme check then rejected, so
@@ -473,7 +473,8 @@ fn log_protocol_resolution(worker_url: &str, enable_http2: Option<bool>, clearte
// revisiting it, so this reading is the only one it will ever get. // revisiting it, so this reading is the only one it will ever get.
(None, _) => tracing::info!( (None, _) => tracing::info!(
worker_url = %worker_url, worker_url = %worker_url,
"no --enable-http2 reading from /server_info; forwarding over HTTP/1.1", "no --enable-http2 reading from /server_info; using the negotiating \
client (HTTP/1.1 in cleartext)",
), ),
// The engine explicitly disabled it. Nothing to explain. // The engine explicitly disabled it. Nothing to explain.
(Some(false), _) => {} (Some(false), _) => {}
@@ -13,19 +13,19 @@ use std::time::Instant;
/// Fixed for the worker's lifetime: it is derived by `manager::resolve_protocol` /// Fixed for the worker's lifetime: it is derived by `manager::resolve_protocol`
/// from the engine's `--enable-http2` launch flag and the dialed URL scheme, /// from the engine's `--enable-http2` launch flag and the dialed URL scheme,
/// neither of which changes while the process runs. The asymmetry that drives /// neither of which changes while the process runs. The asymmetry that drives
/// the default: HTTP/1.1 is accepted by every engine, while h2c is /// the default: the negotiating client is accepted by every engine, while h2c
/// prior-knowledge only and fails outright against an engine that does not /// is prior-knowledge only and fails outright against an engine that does not
/// serve it. /// serve it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WireProtocol { pub enum WireProtocol {
/// HTTP/1.1, in cleartext and over TLS alike. Safe for every engine, so it /// The negotiating client. Safe for every engine, so it is also the
/// is also the fallback. /// fallback.
/// ///
/// Not ALPN-negotiated: `Cargo.toml` builds reqwest with /// HTTP/1.1 in cleartext, ALPN-negotiated over TLS: this crate enables
/// `default-features = false` and does not enable `http2`, so the /// reqwest's `http2` feature (see `Cargo.toml`), so the client advertises
/// forwarding client advertises only `http/1.1` and a TLS engine running /// `h2, http/1.1` and a TLS engine running `--enable-http2` reaches HTTP/2
/// `--enable-http2` still gets HTTP/1.1. Enabling that feature is what /// on its own. Dropping that feature silently reduces this variant to
/// would make this variant negotiate. /// HTTP/1.1 everywhere.
#[default] #[default]
Http1, Http1,
/// Cleartext HTTP/2 with prior knowledge (h2c). Used only when a worker /// Cleartext HTTP/2 with prior knowledge (h2c). Used only when a worker
@@ -1,6 +1,6 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn RUN pip install --no-cache-dir fastapi uvicorn granian
COPY fake_worker.py . COPY fake_worker.py .
EXPOSE 30000 EXPOSE 30000
CMD ["python", "fake_worker.py"] CMD ["python", "fake_worker.py"]
@@ -2,9 +2,21 @@
Responds to: Responds to:
GET /health -> {"status": "ok"} GET /health -> {"status": "ok"}
GET /server_info -> {"served_model_name": MODEL_ID} GET /server_info -> {"served_model_name": MODEL_ID, ...}
GET /v1/models -> list with a single MODEL_ID model entry GET /v1/models -> list with a single MODEL_ID model entry
POST /v1/chat/completions -> echoes the last user message back POST /v1/chat/completions -> echoes the last user message back, plus
the HTTP version the request arrived on
Set `FAKE_WORKER_HTTP2=1` to imitate an engine launched with
`--enable-http2`: `/server_info` advertises the flag and the app is served by
Granian in `HTTPModes.auto`, which is what the real engine runs, so one port
serves cleartext h2c alongside HTTP/1.1. The default is uvicorn, which speaks
HTTP/1.1 only.
`x_http_version` on the chat response is the load-bearing part for
`test_h2c_forwarding.py`: a chat completion returns 200 over either protocol,
so without the worker reporting what it actually received, an h2c test passes
whether or not h2c was used.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,6 +29,16 @@ from fastapi import FastAPI, Request
app = FastAPI() app = FastAPI()
MODEL_ID = os.environ.get("MODEL_ID", "tiny") MODEL_ID = os.environ.get("MODEL_ID", "tiny")
# Normalised before comparing: a manifest that writes the Python-idiomatic
# "False" must not silently turn this worker into a Granian/h2c one, which
# would fail test_h2c_forwarding with a message about a router bug.
ENABLE_HTTP2 = os.environ.get("FAKE_WORKER_HTTP2", "").strip().lower() not in (
"",
"0",
"false",
"no",
"off",
)
@app.get("/health") @app.get("/health")
@@ -27,8 +49,12 @@ async def health():
@app.get("/server_info") @app.get("/server_info")
async def server_info(): async def server_info():
# The sgl-router worker manager fetches this on every Added event and # The sgl-router worker manager fetches this on every Added event and
# uses `served_model_name` to populate the registry's model index. # uses `served_model_name` to populate the registry's model index, and
return {"served_model_name": MODEL_ID} # `enable_http2` to resolve the worker's forwarding protocol.
info = {"served_model_name": MODEL_ID}
if ENABLE_HTTP2:
info["enable_http2"] = True
return info
@app.get("/v1/models") @app.get("/v1/models")
@@ -55,6 +81,11 @@ async def chat_completions(request: Request):
"id": "chatcmpl-mock", "id": "chatcmpl-mock",
"object": "chat.completion", "object": "chat.completion",
"model": payload.get("model", MODEL_ID), "model": payload.get("model", MODEL_ID),
# Non-standard, and deliberately so: the router returns the upstream
# body verbatim (`proxy::forward_*` hands back `resp.bytes()`), so this
# is how a test on the other side of the router learns which protocol
# the forward leg actually used. "1.1" or "2".
"x_http_version": request.scope.get("http_version"),
"choices": [ "choices": [
{ {
"index": 0, "index": 0,
@@ -70,4 +101,20 @@ async def chat_completions(request: Request):
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=30000) if ENABLE_HTTP2:
# Mirrors the engine's own server (`_run_granian_server` in
# sglang/srt/entrypoints/http_server.py): HTTPModes.auto dispatches per
# connection on the first bytes, so h2c prior-knowledge and HTTP/1.1
# share one cleartext port.
from granian import Granian
from granian.constants import HTTPModes, Interfaces
Granian(
target="fake_worker:app",
address="0.0.0.0",
port=30000,
interface=Interfaces.ASGI,
http=HTTPModes.auto,
).serve()
else:
uvicorn.run(app, host="0.0.0.0", port=30000)
@@ -0,0 +1,198 @@
"""E2E: the router forwards to an h2c-capable worker over cleartext HTTP/2.
The in-process tests (`tests/proxy/h2c_forward.rs`, `inbound_h2c.rs`) already
drive real HTTP/2 sockets, and dropping reqwest's `http2` feature fails the
build outright, so neither the client nor the framing needs covering again
here. What no in-process test can assemble is the *chain*: a worker discovered
through a real EndpointSlice, introspected over the network, resolved to
`WireProtocol::H2c` from its own `/server_info`, and then actually forwarded to
over h2c.
The fleet is deliberately mixed. `setup.sh` leaves three uvicorn workers
(HTTP/1.1 only) behind the `app=sglang` Service; this module adds one Granian
worker reporting `enable_http2: true` to the same Service, so both protocols
must be in use simultaneously. That is the e2e form of the per-worker-protocol
property: a router that resolved one protocol fleet-wide would either fail
against the h2c worker or break the three HTTP/1.1 ones, and either way this
test fails.
"""
from __future__ import annotations
import httpx
import pytest
from conftest import (
NAMESPACE,
_apply_from_stdin,
_kubectl,
_poll_until,
_wait_for_deployment_ready,
logger,
)
H2C_DEPLOYMENT = "fake-worker-h2c"
# Round-robin over a 4-worker pool: 12 requests give every worker ~3 turns, so
# a miss means a routing or resolution failure rather than an unlucky draw.
_PROBE_REQUESTS = 12
# Kept low enough that a whole probe round (12 x 5 s worst case) fits inside the
# 90 s poll budget below. A fake worker answers instantly; a request that needs
# more than 5 s is already a failure, and letting a round outlast its own poll
# would make that timeout non-binding.
_CHAT_TIMEOUT = 5.0
_CONVERGE_TIMEOUT = 90
_H2C_WORKER_MANIFEST = f"""
apiVersion: apps/v1
kind: Deployment
metadata:
name: {H2C_DEPLOYMENT}
namespace: {NAMESPACE}
spec:
replicas: 1
selector:
matchLabels:
app: sglang-h2c
template:
metadata:
labels:
app: sglang-h2c
spec:
containers:
- name: worker
image: sgl-router-fake-worker:e2e
imagePullPolicy: Never
env:
- name: FAKE_WORKER_HTTP2
value: "1"
- name: MODEL_ID
value: "tiny"
ports:
- containerPort: 30000
readinessProbe:
httpGet:
path: /health
port: 30000
initialDelaySeconds: 2
periodSeconds: 3
---
apiVersion: v1
kind: Service
metadata:
name: {H2C_DEPLOYMENT}
namespace: {NAMESPACE}
# The router watches ENDPOINTSLICES whose labels match `--selector
# app=sglang`, and Kubernetes mirrors a Service's labels onto the slices it
# manages -- so this label, not the pods', is what puts these workers in the
# router's view.
labels:
app: sglang
spec:
# Pods are labelled `app: sglang-h2c`, deliberately NOT `app: sglang`: the
# fake-worker Deployment's selector is a bare `app=sglang`, so sharing that
# label would put these pods inside another controller's selector and into
# the HTTP/1.1 Service as well.
selector:
app: sglang-h2c
ports:
- port: 30000
targetPort: 30000
"""
@pytest.fixture(scope="module")
def h2c_worker(k8s_cluster):
"""Add one Granian/h2c worker, behind its own Service, to the router's view.
Its own Service rather than the existing one: the router selects
EndpointSlices, so a second Service labelled `app: sglang` is watched just
the same, while its pods stay out of the `fake-worker` Deployment's bare
`app=sglang` selector. Torn down afterwards so the suite's other modules
see the three-worker fleet they expect.
"""
_apply_from_stdin(_H2C_WORKER_MANIFEST)
try:
_wait_for_deployment_ready(H2C_DEPLOYMENT)
yield
finally:
for kind in ("deployment", "service"):
_kubectl(
"delete",
kind,
H2C_DEPLOYMENT,
"-n",
NAMESPACE,
"--ignore-not-found",
"--wait=true",
check=False,
)
def _chat(router_url: str, content: str) -> httpx.Response:
return httpx.post(
f"{router_url}/v1/chat/completions",
json={
"model": "tiny",
"messages": [{"role": "user", "content": content}],
"stream": False,
},
timeout=_CHAT_TIMEOUT,
)
def _observed_protocols(router_url: str, *, strict: bool) -> set[str]:
"""Fan out round-robin and collect the HTTP version each worker saw.
`x_http_version` is reported by the worker itself, not inferred from the
client side: the test's own connection to the router is a separate hop, so
only the worker can say what the forward leg used. Distinct content per
request keeps any content-derived routing from collapsing onto one worker.
`strict=False` while converging. The router runs `--cb-threshold 1`
(manifests/router.yaml), so one refused connection to the still-starting h2c
pod opens its breaker and round-robin hands back a 502 for that turn. That
is precisely what the poll is meant to wait out — and `conftest._poll_until`
retries only transport-level errors, so an `AssertionError` raised here
would escape the retry budget and fail the test on the first blip. Skip
non-200s while converging; assert on them once converged.
"""
seen: set[str] = set()
for i in range(_PROBE_REQUESTS):
r = _chat(router_url, f"h2c-probe-{i}")
if r.status_code != 200:
if strict:
raise AssertionError(f"request {i} failed {r.status_code}: {r.text}")
continue
version = r.json().get("x_http_version")
# Fatal either way: this is a stale fake-worker image or a router that
# stopped returning the upstream body verbatim, neither of which a retry
# fixes, and without it the test cannot tell h2c from HTTP/1.1 at all.
assert version is not None, (
"worker did not report `x_http_version` — the fake-worker image is "
"stale, or the router stopped returning the upstream body verbatim; "
"either way this test cannot tell h2c from HTTP/1.1"
)
seen.add(version)
logger.info("protocols observed across %d requests: %s", _PROBE_REQUESTS, seen)
return seen
def test_router_forwards_over_h2c_to_an_http2_worker(router_url, h2c_worker):
"""A worker advertising `enable_http2` is reached over HTTP/2, and the
HTTP/1.1 workers alongside it keep their own protocol."""
# The router must first see the new pod's EndpointSlice entry and
# introspect it; until then every response comes back "1.1".
_poll_until(
lambda: "2" in _observed_protocols(router_url, strict=False),
"router forwards to the h2c worker over HTTP/2",
timeout=_CONVERGE_TIMEOUT,
interval=5,
)
seen = _observed_protocols(router_url, strict=True)
assert "2" in seen, f"expected an HTTP/2 forward, saw {seen}"
assert "1.1" in seen, (
f"expected the three uvicorn workers to stay on HTTP/1.1, saw {seen}; "
"a fleet-wide protocol would have taken them with it"
)
@@ -12,7 +12,7 @@ use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext; use sgl_router::server::app_context::AppContext;
use sgl_router::server::routes::chat::MAX_CHAT_BODY_BYTES; use sgl_router::server::routes::chat::MAX_CHAT_BODY_BYTES;
use sgl_router::tokenizer::TokenizerRegistry; use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::{Worker, WorkerRegistry}; use sgl_router::workers::{WireProtocol, Worker, WorkerRegistry};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
@@ -839,6 +839,7 @@ async fn forward_json_to_records_failure_on_body_drop() {
let res: Result<_, ApiError> = proxy let res: Result<_, ApiError> = proxy
.forward_json_to( .forward_json_to(
&worker.url, &worker.url,
WireProtocol::Http1,
&breaker, &breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -895,6 +896,7 @@ async fn forward_json_to_records_success_only_after_body_completes() {
let res: Result<_, ApiError> = proxy let res: Result<_, ApiError> = proxy
.forward_json_to( .forward_json_to(
&ok_worker.url, &ok_worker.url,
WireProtocol::Http1,
&breaker, &breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -945,6 +947,7 @@ async fn forward_streaming_to_records_failure_on_mid_stream_drop() {
let res: Result<_, ApiError> = proxy let res: Result<_, ApiError> = proxy
.forward_streaming_to( .forward_streaming_to(
&worker.url, &worker.url,
WireProtocol::Http1,
&breaker, &breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -995,6 +998,7 @@ async fn forward_json_to_records_failure_on_5xx() {
let _: Result<_, ApiError> = proxy let _: Result<_, ApiError> = proxy
.forward_json_to( .forward_json_to(
&worker.url, &worker.url,
WireProtocol::Http1,
&breaker, &breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -1028,6 +1032,7 @@ async fn forward_json_to_rejects_when_breaker_open() {
let res = proxy let res = proxy
.forward_json_to( .forward_json_to(
&worker.url, &worker.url,
WireProtocol::Http1,
&breaker, &breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -1065,6 +1070,7 @@ async fn forward_json_to_malformed_url_returns_worker_misconfigured_and_trips_br
let res = proxy let res = proxy
.forward_json_to( .forward_json_to(
"not-a-url", "not-a-url",
WireProtocol::Http1,
&breaker, &breaker,
"/v1/chat/completions", "/v1/chat/completions",
&headers, &headers,
@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Cleartext-HTTP/2 (h2c) forwarding tests for [`Proxy`].
//!
//! These spin up an **HTTP/2-only** server (hyper's `http2::Builder`, no
//! HTTP/1.1 path) and drive `forward_json_to` against it with an explicit
//! per-request [`WireProtocol`]. Together the two tests prove what a value-only
//! unit test cannot: that the proxy's h2c client genuinely speaks HTTP/2 on the
//! wire when a request selects [`WireProtocol::H2c`] (not just that the value
//! was threaded through), and that the HTTP/1.1 client cannot reach an h2c
//! worker — so a regression that dropped the `http2` Cargo feature, removed
//! `http2_prior_knowledge()`, or mismatched `build_client`'s arms would fail
//! here rather than slip through. They also pin the per-worker design: the
//! protocol is chosen per `forward_json_to` call, not committed fleet-wide.
use std::convert::Infallible;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full, StreamBody};
use hyper::body::Frame;
use hyper::server::conn::http2;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo};
use sgl_router::health::circuit_breaker::CircuitBreaker;
use sgl_router::proxy::Proxy;
use sgl_router::workers::WireProtocol;
use tokio::net::TcpListener;
/// Serve HTTP/2 only. `http2::Builder::serve_connection` speaks the HTTP/2
/// framing protocol with no HTTP/1.1 fallback, so a client that does not
/// send the HTTP/2 connection preface cannot complete a request. Returns the
/// base URL; the accept loop is aborted when the test runtime shuts down.
async fn spawn_h2c_only_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
let _ = http2::Builder::new(TokioExecutor::new())
.serve_connection(
TokioIo::new(stream),
service_fn(|_req: Request<hyper::body::Incoming>| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(
b"{\"ok\":true}",
))))
}),
)
.await;
});
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn h2c_client_reaches_http2_only_worker() {
let url = spawn_h2c_only_server().await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
let breaker = CircuitBreaker::new();
// Select h2c per request, as the chat handler does for a worker whose
// `/server_info` reported --enable-http2 on a cleartext URL.
let resp = proxy
.forward_json_to(
&url,
WireProtocol::H2c,
&breaker,
"/v1/chat/completions",
&axum::http::HeaderMap::new(),
Bytes::from_static(b"{}"),
)
.await
.expect("h2c client must reach an HTTP/2-only worker");
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn http1_client_cannot_reach_http2_only_worker() {
let url = spawn_h2c_only_server().await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
let breaker = CircuitBreaker::new();
// The HTTP/1.1 client never sends the HTTP/2 preface, so the h2c-only
// server cannot serve it. This is what makes selecting the protocol
// meaningful: Http1 and H2c are different protocols on the wire, not the
// same client pointed at the same endpoint.
let res = proxy
.forward_json_to(
&url,
WireProtocol::Http1,
&breaker,
"/v1/chat/completions",
&axum::http::HeaderMap::new(),
Bytes::from_static(b"{}"),
)
.await;
assert!(
res.is_err(),
"HTTP/1.1 client must not complete a request against an HTTP/2-only worker, got {res:?}",
);
}
/// Serve HTTP/2 only, answering with a multi-frame SSE body. Each `data:`
/// chunk is its own HTTP/2 DATA frame, which is the framing the real engine
/// produces for a streaming generation.
async fn spawn_h2c_only_sse_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
let _ = http2::Builder::new(TokioExecutor::new())
.serve_connection(
TokioIo::new(stream),
service_fn(|_req: Request<hyper::body::Incoming>| async {
let chunks: Vec<Result<Frame<Bytes>, Infallible>> = vec![
Ok(Frame::data(Bytes::from_static(b"data: {\"i\":0}\n\n"))),
Ok(Frame::data(Bytes::from_static(b"data: {\"i\":1}\n\n"))),
Ok(Frame::data(Bytes::from_static(b"data: [DONE]\n\n"))),
];
let body = StreamBody::new(futures::stream::iter(chunks));
Ok::<_, Infallible>(
Response::builder()
.header("content-type", "text/event-stream")
.body(body)
.unwrap(),
)
}),
)
.await;
});
}
});
format!("http://{addr}")
}
/// The streaming forward — the path every chat generation actually takes — must
/// work over h2c, and must deliver the whole multi-frame body.
///
/// `forward_streaming_to` differs from `forward_json_to` in ways HTTP/2 reaches
/// differently: it omits the request timeout, and it hands `bytes_stream()` to
/// the SSE pump rather than buffering. A break confined to SSE-over-h2c — a
/// truncated body, a stall, a mid-stream reset surfacing as success — leaves
/// the buffered tests green, so cover it directly.
#[tokio::test]
async fn h2c_client_streams_sse_from_http2_only_worker() {
let url = spawn_h2c_only_sse_server().await;
let proxy = Proxy::new(Duration::from_secs(5)).unwrap();
let breaker = Arc::new(CircuitBreaker::new());
let first_byte_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = Arc::clone(&first_byte_seen);
let resp = proxy
.forward_streaming_to(
&url,
WireProtocol::H2c,
&breaker,
"/v1/chat/completions",
&axum::http::HeaderMap::new(),
Bytes::from_static(b"{}"),
None,
Some(Box::new(move || {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
})),
None,
)
.await
.expect("h2c client must stream from an HTTP/2-only worker");
assert_eq!(resp.status(), 200);
let body = resp
.into_body()
.collect()
.await
.expect("streaming body must complete over h2c")
.to_bytes();
let text = String::from_utf8(body.to_vec()).unwrap();
// Every frame arrives, in order — not just the first.
assert!(text.contains("\"i\":0"), "missing first chunk: {text}");
assert!(text.contains("\"i\":1"), "missing second chunk: {text}");
assert!(
text.contains("[DONE]"),
"stream truncated before DONE: {text}"
);
assert!(
first_byte_seen.load(std::sync::atomic::Ordering::SeqCst),
"on_first_byte must fire for an h2c stream (TTFT accounting depends on it)",
);
}
@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Inbound cleartext-HTTP/2 (h2c) listener tests for the router's own server.
//!
//! Where `h2c_forward.rs` proves the router's *outbound* client speaks h2c to
//! an HTTP/2-only worker, these prove the *inbound* side: the router's
//! `axum::serve` listener accepts an h2c prior-knowledge client on the same
//! cleartext port it serves HTTP/1.1 on, auto-negotiating per connection.
//!
//! Protocol negotiation happens at the connection layer, below the tower
//! service — so unlike the `oneshot` tests in `chat_routing.rs`, these must
//! drive a real socket via `axum::serve` (mirroring `main.rs`).
//!
//! What actually compiles the listener's h2 path is `hyper-util/http2`, reached
//! through `server-auto`, and several dependencies enable it. So these tests do
//! NOT fail if axum's own `http2` feature is dropped — verified by removing it.
//! Their job is the property an operator depends on, whatever the feature graph
//! happens to look like: one cleartext port serving both h2c prior-knowledge
//! and HTTP/1.1. That is what would break if a dependency change silently took
//! `hyper-util/http2` away.
use std::sync::Arc;
use std::time::Duration;
use sgl_router::config::PolicyKind;
use sgl_router::policies::factory::build_registry_with_defaults as build_policy_registry;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use axum::http::Version;
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
fn build_ctx() -> Arc<AppContext> {
// Reuse the shared fixture rather than restating the whole `Config`: this
// test is about the listener, not the routing policy, and a local literal
// would be one more site to edit every time `ModelConfig` gains a field.
let mut cfg = crate::common::cache_aware_fixture::config();
cfg.model.policy = PolicyKind::RoundRobin;
cfg.model.cache_aware = None;
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
let policies = Arc::new(build_policy_registry(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(TEST_TIMEOUT).unwrap());
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
/// Spawn the real router (`build_router`) behind `axum::serve` on an ephemeral
/// port — the exact serve path used in `main.rs`. `/healthz` returns 200
/// unconditionally, so no worker is needed. Returns the base URL; the accept
/// loop is dropped when the test runtime shuts down.
async fn spawn_router() -> String {
let ctx = build_ctx();
ctx.mark_ready();
let app = build_router(ctx);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
format!("http://{addr}")
}
#[tokio::test]
async fn inbound_accepts_h2c_prior_knowledge() {
let base = spawn_router().await;
// `http2_prior_knowledge()` sends the HTTP/2 connection preface directly
// over cleartext (no ALPN, no h1 upgrade) — the same way a service-mesh
// sidecar dials h2c. The listener must recognize the preface and serve h2.
let client = reqwest::Client::builder()
.http2_prior_knowledge()
.build()
.unwrap();
let resp = client
.get(format!("{base}/healthz"))
.send()
.await
.expect("h2c prior-knowledge client must reach the router listener");
assert_eq!(resp.status(), 200);
assert_eq!(
resp.version(),
Version::HTTP_2,
"listener must negotiate HTTP/2 for an h2c prior-knowledge client",
);
}
#[tokio::test]
async fn inbound_still_accepts_http1() {
let base = spawn_router().await;
// The default reqwest client speaks HTTP/1.1 over cleartext. Enabling h2c
// must not break existing HTTP/1.1 callers (load balancers, probes, curl)
// — the auto builder serves both on the same port.
let client = reqwest::Client::new();
let resp = client
.get(format!("{base}/healthz"))
.send()
.await
.expect("HTTP/1.1 client must still reach the router listener");
assert_eq!(resp.status(), 200);
assert_eq!(
resp.version(),
Version::HTTP_11,
"an HTTP/1.1 client must keep negotiating HTTP/1.1 on the same port",
);
}
@@ -16,9 +16,12 @@ mod chat_routing;
mod external_indexer_routing; mod external_indexer_routing;
mod failover; mod failover;
mod graceful_shutdown; mod graceful_shutdown;
mod h2c_forward;
mod header_forwarding; mod header_forwarding;
mod inbound_h2c;
mod pd_bootstrap_injection; mod pd_bootstrap_injection;
mod pd_pool_isolation; mod pd_pool_isolation;
mod pd_protocol_binding;
mod radix_tree_routing; mod radix_tree_routing;
mod roundrobin_input_ids; mod roundrobin_input_ids;
mod shared_prefill_admission; mod shared_prefill_admission;
@@ -0,0 +1,260 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Binds protocol *resolution* to protocol *use*, across the PD split.
//!
//! Everywhere else the two halves are tested apart: the manager tests assert
//! what lands on the registry, and `h2c_forward.rs` passes a `WireProtocol` to
//! the proxy by hand. Nothing asserts that the protocol a worker resolved to is
//! the one its own forward actually uses — and in the PD arm of
//! `chat_completions` that join is three separate expressions, a
//! `prefill_protocol` captured before a `tokio::spawn` plus two live
//! `decode_worker.protocol()` reads. Passing the wrong one of those compiles,
//! and every other test in the suite stays green.
//!
//! So make the two workers disagree and let the wire enforce it: the prefill
//! mock speaks **HTTP/2 only**, the decode mock speaks **HTTP/1.1 only**, and
//! each is registered with the matching protocol. Any mix-up sends a client at
//! a server that cannot answer it. This is also why neither mock can be the
//! shared `MockWorker` — that one runs on `axum::serve`, which answers both
//! protocols and would pass no matter which was selected.
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::server::conn::{http1, http2};
use hyper::service::service_fn;
use hyper::Response as HyperResponse;
use hyper_util::rt::{TokioExecutor, TokioIo};
use sgl_router::config::{
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::{WireProtocol, WorkerRegistry};
use tokio::net::TcpListener;
use tower::ServiceExt;
/// A chat-completions response shaped enough for the handler to return 200.
fn chat_completion_json() -> Bytes {
Bytes::from(
serde_json::json!({
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "tiny",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
})
.to_string(),
)
}
/// Serve HTTP/2 only, recording that a request body arrived. An HTTP/1.1
/// client cannot complete a request here — it never sends the HTTP/2 preface.
async fn spawn_h2_only_worker(hits: Arc<Mutex<usize>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let hits = Arc::clone(&hits);
tokio::spawn(async move {
let _ = http2::Builder::new(TokioExecutor::new())
.serve_connection(
TokioIo::new(stream),
service_fn(move |_req| {
let hits = Arc::clone(&hits);
async move {
*hits.lock().unwrap() += 1;
Ok::<_, Infallible>(HyperResponse::new(Full::new(
chat_completion_json(),
)))
}
}),
)
.await;
});
}
});
format!("http://{addr}")
}
/// Serve HTTP/1.1 only. `http1::Builder` speaks no HTTP/2, so an h2c client
/// sending the preface cannot be served here.
async fn spawn_http1_only_worker(hits: Arc<Mutex<usize>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let hits = Arc::clone(&hits);
tokio::spawn(async move {
let _ = http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(move |_req| {
let hits = Arc::clone(&hits);
async move {
*hits.lock().unwrap() += 1;
Ok::<_, Infallible>(HyperResponse::new(Full::new(
chat_completion_json(),
)))
}
}),
)
.await;
});
}
});
format!("http://{addr}")
}
fn config() -> Config {
Config {
server: ServerConfig {
host: "0".into(),
port: 0,
},
observability: ObservabilityConfig::default(),
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
circuit_breaker: None,
cache_aware: None,
sticky: None,
affinity: None,
fused: None,
eligibility: None,
},
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
urls: vec!["http://placeholder:0".into()],
}),
proxy: ProxyConfig::default(),
active_load: ActiveLoadConfig::default(),
}
}
/// Register both workers with their own resolved protocol, the way
/// `manager::register_one` does after introspecting `/server_info`.
fn build_ctx(prefill_url: String, decode_url: String) -> Arc<AppContext> {
let cfg = config();
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
let registry = Arc::new(WorkerRegistry::default());
let prefill_id = WorkerId("p1".into());
let decode_id = WorkerId("d1".into());
// The two disagree on purpose. This is the state a mixed fleet reaches
// when only some engines run with --enable-http2.
registry
.add_with_cb(
WorkerSpec {
id: prefill_id.clone(),
url: prefill_url,
mode: WorkerMode::Prefill,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: Some(8997),
},
None,
WireProtocol::H2c,
)
.unwrap();
registry
.add_with_cb(
WorkerSpec {
id: decode_id.clone(),
url: decode_url,
mode: WorkerMode::Decode,
model_ids: vec![ModelId("tiny".into())],
bootstrap_port: None,
},
None,
WireProtocol::Http1,
)
.unwrap();
let policies = Arc::new(build_registry_with_defaults(&cfg).unwrap());
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
fn chat_request() -> Request<Body> {
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"model": "tiny",
"messages": [{"role": "user", "content": "hi"}],
"stream": false
}))
.unwrap(),
))
.unwrap()
}
/// Each PD leg must forward over the protocol *its own* worker resolved.
///
/// The decode leg is awaited, so a protocol mix-up there fails the response
/// outright. The prefill leg is detached, so its mix-up shows up as a body that
/// never arrives — poll for it rather than reading once.
#[tokio::test]
async fn pd_legs_each_use_their_own_workers_protocol() {
let prefill_hits = Arc::new(Mutex::new(0usize));
let decode_hits = Arc::new(Mutex::new(0usize));
let prefill_url = spawn_h2_only_worker(Arc::clone(&prefill_hits)).await;
let decode_url = spawn_http1_only_worker(Arc::clone(&decode_hits)).await;
let app = build_router(build_ctx(prefill_url, decode_url));
let res = app.oneshot(chat_request()).await.unwrap();
// Decode answered, so the decode leg used HTTP/1.1 — had it been handed the
// prefill worker's H2c, this HTTP/1.1-only server could not have replied.
assert_eq!(
res.status(),
StatusCode::OK,
"decode leg must forward over the decode worker's own protocol (HTTP/1.1)",
);
let body = res.into_body().collect().await.unwrap().to_bytes();
assert!(
!body.is_empty(),
"decode response body must reach the client",
);
// Prefill is spawn-and-forget; it races the response back to the client.
let reached = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if *prefill_hits.lock().unwrap() > 0 {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await;
assert!(
reached.is_ok(),
"prefill leg must forward over the prefill worker's own protocol (h2c); \
an HTTP/1.1 client cannot reach this HTTP/2-only worker",
);
assert_eq!(
*decode_hits.lock().unwrap(),
1,
"decode worker should be hit exactly once",
);
}