Skip to content

client: resolve the gRPC unary response status before the HTTP status - #257

Merged
iainmcgin merged 1 commit into
mainfrom
fix/grpc-unary-response-divergences
Aug 21, 2026
Merged

client: resolve the gRPC unary response status before the HTTP status#257
iainmcgin merged 1 commit into
mainfrom
fix/grpc-unary-response-divergences

Conversation

@iainmcgin

@iainmcgin iainmcgin commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes #241
Fixes #242

Two divergences between the unary gRPC response parser and its streaming sibling.

#241 — a proxy's trailers-only error lost its message and metadata on unary calls. The unary path checked the HTTP status first and returned immediately, so grpc-status was never consulted. Given an Envoy local reply carrying 503 alongside grpc-status: 14 and grpc-message: upstream connect error, a server-streaming call reported Unavailable("upstream connect error") with the server's metadata and a unary call reported Unavailable("HTTP error 503") with both discarded.

The unary path now resolves the gRPC status whenever the reply plausibly speaks gRPC, and keeps the HTTP-derived code otherwise. The header status is honoured only for a genuine trailers-only response, tested as !has_body_data.

#242 — on plain gRPC, a high bit in the first body byte could overwrite the real trailers. The gRPC-Web trailer-frame check appears twice in this function and only the first was gated on the protocol. 0x80 is the gRPC-Web trailer sentinel and is not a defined flag in plain gRPC, but Envelope::decode_with_limit does not validate unknown flag bits, so the frame was parsed and grpc_trailers was replaced — including over trailers already captured from the HTTP/2 trailer section. Both sites are gated now, and the sentinel gets a name.

Verified against real Go implementations

Both changes are wire-visible interpretation, and neither case is reachable through conformance — the suite has no non-200 raw-response case and no conformant server emits 0x80. So I built a raw-h2c harness emitting each shape exactly and drove connect-go v1.19.1, grpc-go v1.81.1, this branch and its base against identical bytes.

The premise in #241 was half wrong, in our favour. connect-go does check the HTTP status first — grpcValidateResponse returns httpToCode(status) before touching content-type or any gRPC status. grpc-go does not: a valid gRPC content-type sets isGRPC and the HTTP-status branch is never entered. Measured on the Envoy shape, grpc-go returns Unavailable("upstream connect error") and so does this branch, byte for byte; on 500 + grpc-status: 3 both return InvalidArgument("bad argument"). It is the base tree that matched connect-go. So this matches grpc-go and diverges from connect-go, on a case where connect-go discards the server's code, message and metadata entirely.

On 0x80, both Go clients already reject the frame — connect-go with "invalid envelope flags 128", grpc-go with "received unexpected payload format 128". The base tree was the outlier: it parsed the attacker's frame as gRPC-Web trailers and reported NotFound, overriding real HTTP/2 trailers that said grpc-status: 0. This removes a divergence rather than creating one.

No incompatibility is introduced. Across fourteen scenarios there is no case where a Go client completes a call and this branch fails it. Every difference is a different error for a call that fails in all four clients.

The interop run also caught a regression in an earlier revision of this PR: a 502 carrying an HTML page reported Unknown where base, connect-go and grpc-go all report Unavailable, losing the retryability signal. Fixed, and the rule is now applied wherever a body fails to parse, so the two spellings of the same proxy failure cannot produce different retry decisions.

One pre-existing divergence found and deliberately not adopted: grpc-go accepts a successful response delivered on HTTP 503. We and connect-go reject it, which seems right.

Conformance

The trailers-only gate is load-bearing and an earlier revision got it wrong — hoisting the header check above the body read fails four cases named trailers-only/ignore-header-if-body-present, measured at 12 passed / 4 failed versus 16/16 for this one. Full suites: gRPC-Web client 2838/0, gRPC client 1452/1454 with both failures in the load-induced Timeouts flake that is equally present on base, and the gRPC Unexpected Responses subset 22/0.

One note on CI: two handler::tests element-budget tests fail on main right now, independently of this change — fixture rot from buffa 0.9.1, fixed by #239.

@iainmcgin
iainmcgin force-pushed the fix/grpc-unary-response-divergences branch from c5cce68 to 438e7f3 Compare July 24, 2026 18:33
@iainmcgin

Copy link
Copy Markdown
Collaborator Author

The conformance gap this PR relies on is now filed upstream as connectrpc/conformance#1119 (non-200 carrying a gRPC status — zero cases pair the two, and connect-go and grpc-go disagree) and connectrpc/conformance#1120 (reserved envelope flag bytes).

#1119 is the one that bears on reviewing this PR: it records that the divergence is between the two reference implementations rather than something we invented, with the measured behaviour of each and the mechanism in their source. If upstream settles the question one way, this PR's choice should follow it.

Two divergences between the unary gRPC response parser and its streaming
sibling.

A trailers-only error carries its status in the initial headers, and a
proxy may pair those headers with a non-200 status - an Envoy local reply
sends 503 alongside `grpc-status: 14`. The unary path reported only
`HTTP error 503` and discarded the server's code, message and metadata.
It now resolves the gRPC status whenever the reply plausibly speaks gRPC,
falling back to the HTTP status otherwise. The trailers-only test is
`!has_body_data`, matching the streaming classifier and the conformance
suite's ignore-header-if-body-present cases.

This matches grpc-go, which decides gRPC-ness from the content-type and
disregards the HTTP status entirely, and diverges from connect-go, which
decides from the HTTP status and never reads the gRPC one - discarding the
server's code, message and metadata in the process. Verified by running
both against the same wire bytes.

A reply that does not speak gRPC keeps the HTTP-derived code, so a 502
carrying an HTML error page is still `Unavailable` and still retryable,
with the content-type or framing failure appended as detail. That rule is
applied wherever a body fails to parse, so the two spellings of the same
proxy failure cannot produce different retry decisions.

The gRPC-Web trailer-frame check appears twice in the same function and
only the first was gated on the protocol. `0x80` is not a defined flag in
plain gRPC and envelope decoding ignores unknown bits, so a body frame
with the high bit set was parsed as a trailer block and replaced trailers
already captured from the HTTP/2 trailer section. Both Go clients reject
such a frame; we now do too.

Adds `envelope::flags::GRPC_WEB_TRAILER` and moves the remaining raw
flag-bit tests onto the named constants.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin
iainmcgin added this pull request to the merge queue Aug 21, 2026
Merged via the queue into main with commit ca85dff Aug 21, 2026
14 checks passed
@iainmcgin
iainmcgin deleted the fix/grpc-unary-response-divergences branch August 21, 2026 02:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants