Version
hyper 1.10.1 h2 0.4.15
Platform
Linux zen 7.1.3-arch1-3.1 #1 SMP PREEMPT_DYNAMIC Tue, 14 Jul 2026 13:20:11 +0000 x86_64 GNU/Linux
Summary
An HTTP/2 hyper proxy changes the framing of headers-only responses when the server follows it with RST_STREAM(NO_ERROR).
The upstream server sends:
HEADERS + END_STREAM
RST_STREAM(NO_ERROR)
The proxy sends downstream:
HEADERS
empty DATA + END_STREAM
RST_STREAM(NO_ERROR)
This matters for any protocol where the final metadata is carried in the header-only response. I encountered it with a gRPC trailers-only response. gRPC clients assume that if the HEADERS frame does not contain END_STREAM, then there will be a trailers HEADER frame that does.
Code Sample
# Run server with docker run -it --rm -p 9000:9000 -p 9001:9001 moul/grpcbin
use std::convert::Infallible;
use http::Request;
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::{TcpListener, TcpStream};
use tokio::process::Command;
const BACKEND: &str = "127.0.0.1:9000";
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy = listener.local_addr()?.to_string();
tokio::spawn(run_proxy(listener));
grpcurl("direct", BACKEND).await?;
grpcurl("through proxy", &proxy).await?;
Ok(())
}
async fn run_proxy(listener: TcpListener) {
loop {
let (socket, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let service = service_fn(|request: Request<Incoming>| async move {
let upstream = TcpStream::connect(BACKEND).await.unwrap();
let (mut sender, connection) =
hyper::client::conn::http2::Builder::new(TokioExecutor::new())
.handshake(TokioIo::new(upstream))
.await
.unwrap();
tokio::spawn(async move {
let _ = connection.await;
});
Ok::<_, Infallible>(sender.send_request(request).await.unwrap())
});
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(socket), service)
.await;
});
}
}
async fn grpcurl(
label: &str,
address: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!("--- {label} ---");
let status = Command::new("grpcurl")
.arg("-plaintext")
.arg("-d")
.arg(r#"{"greeting":"x"}"#)
.arg(address)
.arg("hello.HelloService/SayHello")
.status()
.await?;
println!("{status}\n");
Ok(())
}
Expected Behavior
--- direct ---
{
"reply": "hello x"
}
exit status: 0
--- through proxy ---
Error invoking method "hello.HelloService/SayHello": rpc error: code = Internal desc = failed to query for service descriptor "hello.HelloService": server closed the stream without sending trailers
exit status: 1
Expected: gRPC traffic can proxy through a hyper reverse proxy succesfully.
Actual Behavior
gRPC client breaks
Additional Context
Below is my guesses on this, may be wrong.
After receiving the upstream response, Hyper exposes:
response headers: present
body.is_end_stream(): false
first body frame: None
Incoming::poll_frame suppresses RST_STREAM(NO_ERROR) by returning None, which is appropriate for an early response. However, the body no longer reports that the response HEADERS already carried END_STREAM.
When that response is returned from a Hyper server, Hyper emits ordinary HEADERS followed by an empty DATA frame with END_STREAM.
A proxy cannot reconstruct the original framing without buffering or using protocol-specific response headers.
Maybe related
Version
hyper 1.10.1 h2 0.4.15
Platform
Linux zen 7.1.3-arch1-3.1 #1 SMP PREEMPT_DYNAMIC Tue, 14 Jul 2026 13:20:11 +0000 x86_64 GNU/Linux
Summary
An HTTP/2 hyper proxy changes the framing of headers-only responses when the server follows it with
RST_STREAM(NO_ERROR).The upstream server sends:
The proxy sends downstream:
This matters for any protocol where the final metadata is carried in the header-only response. I encountered it with a gRPC trailers-only response. gRPC clients assume that if the HEADERS frame does not contain END_STREAM, then there will be a trailers HEADER frame that does.
Code Sample
Expected Behavior
Expected: gRPC traffic can proxy through a hyper reverse proxy succesfully.
Actual Behavior
gRPC client breaks
Additional Context
Below is my guesses on this, may be wrong.
After receiving the upstream response, Hyper exposes:
Incoming::poll_framesuppressesRST_STREAM(NO_ERROR)by returningNone, which is appropriate for an early response. However, the body no longer reports that the response HEADERS already carriedEND_STREAM.When that response is returned from a Hyper server, Hyper emits ordinary HEADERS followed by an empty DATA frame with
END_STREAM.A proxy cannot reconstruct the original framing without buffering or using protocol-specific response headers.
Maybe related
RST_STREAMwithNO_ERRORset for the reason #2872