Skip to content

HTTP/2 proxy loses END_STREAM on early header-only response #4130

Description

@howardjohn

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-bugCategory: bug. Something is wrong. This is bad!

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions