You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
grpc-web transport: unary() discards a valid Grpc-Status header on trailers-only responses (fix from #1161/#1209 not ported from protocol-grpc) #1764
packages/connect/src/protocol-grpc-web/transport.ts's unary() discards a valid headerError (derived from a Grpc-Status response header) when the body's trailer frame is present but parses to an empty Headers object instead of undefined.
This is the same bug reported against the connect-node / plain-grpc transport in #1161 and fixed there by #1209 — but that fix only touched protocol-grpc/transport.ts (used by @connectrpc/connect-node's native h2c transport). The grpc-web transport (protocol-grpc-web/transport.ts, used by @connectrpc/connect-web and @connectrpc/connect-node's grpc-web transport) has the identical unfixed pattern.
To Reproduce
Mock httpClient to return a trailers-only-shaped response: status 200, headers content-type: application/grpc-web + grpc-status: 3, body Uint8Array([0x80, 0, 0, 0, 0]) (grpc-web trailer frame, zero-length payload).
Expected: ConnectError code InvalidArgument, taken from the Grpc-Status header.
This happens for real against a grpc-java backend that returns a spec-compliant HTTP/2 Trailers-Only response (single HEADERS frame, END_STREAM, confirmed via raw capture) through a grpc-web proxy that isn't gRPC-semantics-aware and forwards grpc-status as a plain header while emitting an empty grpc-web trailer frame.
The bug
if(trailer===undefined){if(headerError!=undefined){throwheaderError;// never reached: trailer is an empty Headers, not undefined}thrownewConnectError("protocol error: missing trailer", ...);}validateTrailer(trailer,uRes.header);if(message===undefined){thrownewConnectError(// headerError never consulted here"protocol error: missing output message for unary method",trailer.has(headerGrpcStatus) ? Code.Unimplemented : Code.Unknown,);}
protocol-grpc/transport.ts (already fixed) checks headerError directly in the "no message" branch, independent of trailer presence:
if(message===undefined){// Trailers only responseif(headerError){throwheaderError;}thrownewConnectError(...);}
Suggested fix
Port the same check into protocol-grpc-web/transport.ts's unary(): consult headerError in the message === undefined branch, not only when trailer === undefined.
@ajeetdsouza could you take a look at this one? @keon0711 has opened #1773 for it.
At its core this is just the #1209 fix (protocol-grpc) never having been ported to protocol-grpc-web - a single branch in unary(). I've been running that change patched into node_modules locally and my tests pass with it, so I'm fairly confident it's the whole fix.
Browser → Traefik over HTTP/1.1, Traefik → grpc-java backend over h2c. No custom plugins or filters.
You're right that I mislabeled the response — I'll drop the "Trailers-Only" framing. To take my own setup out of the picture, I put a stock @grpc/grpc-js server (both RPCs reject with INVALID_ARGUMENT before sending any headers or messages) behind four gRPC-Web proxies:
proxy
response body
unary()
stream()
Envoy grpc_web
empty
invalid_argument
invalid_argument
improbable-eng grpcwebproxy
empty
invalid_argument
invalid_argument
APISIX grpc-web
empty
invalid_argument
invalid_argument
Traefik v3 grpcWeb
80 00 00 00 00
unknown
invalid_argument
All four put grpc-status: 3 in the response headers. Traefik is the odd one out in also emitting a zero-length trailer frame. I'm filing that with Traefik separately — the frame is information-free by construction: their trailer serializer skips any key it has already flushed as a header, which is exactly grpc-status and grpc-message, so the trailer buffer is empty and the 5-byte frame header gets written anyway.
That said, I think the client-side guard is right regardless of what Traefik does, for three reasons.
1. The reference client accepts this response.grpc/grpc-web — the canonical implementation — reports INVALID_ARGUMENT for the exact same bytes. connect-es is the only client we've tried that turns a header-carried status into unknown.
2. connect-es already treats a header-carried status as authoritative — in two of the three places it looks at one.
stream() throws headerError right after validateResponse(), before reading the body at all — which is why the very same Traefik response is reported correctly for a streaming call (last column above).
validateTrailer() raises missing status only when neither header nor trailer carries grpc-status, so a header-only status is explicitly valid there.
Only unary() computes headerError, passes validateTrailer(), then drops the error and reports unknown — the worst available outcome, since it's indistinguishable from a transport failure. PROTOCOL-WEB.md sanctions the header-carried form ("Trailers may be sent together with response headers, with no message in the body"); connect-es honours it only when the body is byte-for-byte empty.
3. There's precedent in this repo.#1161 was the same shape in protocol-grpc, and #1209 fixed it by adding exactly this guard to the message === undefined branch. This is the same fix in the same place for the other protocol.
Two side notes:
Fix gRPC-web trailers-only error propagation #1773 touches packages/connect/src/protocol-grpc-web/transport.ts only. gRPC-Web unary() is implemented twice — packages/connect-web/src/grpc-web-transport.ts has its own copy with the same branch, and the two fail with different messages (protocol error: missing output message for unary method vs missing message). As it stands Fix gRPC-web trailers-only error propagation #1773 wouldn't fix @connectrpc/connect-web, which is the package this issue was filed against. I've verified locally that applying the guard in both files makes every combination above — two packages, unary and streaming, all four proxies — report invalid_argument.
On why we care rather than just switching proxies: we're migrating off grpc/grpc-web, which handles this fine but costs us roughly 2–3× the bundle (grpc-web + google-protobuf vs @connectrpc/connect + connect-web + @bufbuild/protobuf, before generated code). This is the one thing blocking the move.
One thing I noticed after posting, which may matter more than this bug itself: this is the third time trailers-only handling has been fixed in one place and not the other
What keeps diverging isn't the transport loop, it's the decision of where grpc-status is allowed to come from. validateTrailer() is already shared and already accepts a header-only status, yet each unary() separately re-derives what to do when there is no message. A shared helper for that decision would make it structurally impossible to fix one side only.
Which is also why #1773 deserves a second look: it's scoped to packages/connect/src/protocol-grpc-web/transport.ts, but its body says Fixes #1764: so merging it as-is would close this issue while @connectrpc/connect-web, the package in the report, still returns unknown.
Activity
destitutus commentedon Aug 26, 2026
@ajeetdsouza could you take a look at this one? @keon0711 has opened #1773 for it.
At its core this is just the #1209 fix (protocol-grpc) never having been ported to protocol-grpc-web - a single branch in
unary(). I've been running that change patched intonode_moduleslocally and my tests pass with it, so I'm fairly confident it's the whole fix.ajeetdsouza commentedon Sep 5, 2026
Hi! I had a look at this, but I'm not sure that headers + empty trailer qualifies as a
Trailers-Onlyresponse.Could you elaborate on which proxy you're using, and how it has been configured?
destitutus commentedon Sep 5, 2026
@ajeetdsouza thank you for looking into it.
It is Traefik v3's built-in
grpcWebmiddleware, stock configuration:Browser → Traefik over HTTP/1.1, Traefik → grpc-java backend over h2c. No custom plugins or filters.
You're right that I mislabeled the response — I'll drop the "Trailers-Only" framing. To take my own setup out of the picture, I put a stock
@grpc/grpc-jsserver (both RPCs reject withINVALID_ARGUMENTbefore sending any headers or messages) behind four gRPC-Web proxies:unary()stream()grpc_webinvalid_argumentinvalid_argumentgrpcwebproxyinvalid_argumentinvalid_argumentgrpc-webinvalid_argumentinvalid_argumentgrpcWeb80 00 00 00 00unknowninvalid_argumentAll four put
grpc-status: 3in the response headers. Traefik is the odd one out in also emitting a zero-length trailer frame. I'm filing that with Traefik separately — the frame is information-free by construction: their trailer serializer skips any key it has already flushed as a header, which is exactlygrpc-statusandgrpc-message, so the trailer buffer is empty and the 5-byte frame header gets written anyway.That said, I think the client-side guard is right regardless of what Traefik does, for three reasons.
1. The reference client accepts this response.
grpc/grpc-web— the canonical implementation — reportsINVALID_ARGUMENTfor the exact same bytes. connect-es is the only client we've tried that turns a header-carried status intounknown.2. connect-es already treats a header-carried status as authoritative — in two of the three places it looks at one.
stream()throwsheaderErrorright aftervalidateResponse(), before reading the body at all — which is why the very same Traefik response is reported correctly for a streaming call (last column above).validateTrailer()raisesmissing statusonly when neither header nor trailer carriesgrpc-status, so a header-only status is explicitly valid there.Only
unary()computesheaderError, passesvalidateTrailer(), then drops the error and reportsunknown— the worst available outcome, since it's indistinguishable from a transport failure. PROTOCOL-WEB.md sanctions the header-carried form ("Trailers may be sent together with response headers, with no message in the body"); connect-es honours it only when the body is byte-for-byte empty.3. There's precedent in this repo. #1161 was the same shape in
protocol-grpc, and #1209 fixed it by adding exactly this guard to themessage === undefinedbranch. This is the same fix in the same place for the other protocol.Two side notes:
Fix gRPC-web trailers-only error propagation #1773 touches
packages/connect/src/protocol-grpc-web/transport.tsonly. gRPC-Webunary()is implemented twice —packages/connect-web/src/grpc-web-transport.tshas its own copy with the same branch, and the two fail with different messages (protocol error: missing output message for unary methodvsmissing message). As it stands Fix gRPC-web trailers-only error propagation #1773 wouldn't fix@connectrpc/connect-web, which is the package this issue was filed against. I've verified locally that applying the guard in both files makes every combination above — two packages, unary and streaming, all four proxies — reportinvalid_argument.On why we care rather than just switching proxies: we're migrating off
grpc/grpc-web, which handles this fine but costs us roughly 2–3× the bundle (grpc-web+google-protobufvs@connectrpc/connect+connect-web+@bufbuild/protobuf, before generated code). This is the one thing blocking the move.destitutus commentedon Sep 6, 2026
@ajeetdsouza any thoughts on the above?
One thing I noticed after posting, which may matter more than this bug itself: this is the third time trailers-only handling has been fixed in one place and not the other
protocol-grpcconnect-web/grpc-web-transport.ts(server-streaming only)protocol-grpc-web/transport.ts.What keeps diverging isn't the transport loop, it's the decision of where
grpc-statusis allowed to come from.validateTrailer()is already shared and already accepts a header-only status, yet eachunary()separately re-derives what to do when there is no message. A shared helper for that decision would make it structurally impossible to fix one side only.Which is also why #1773 deserves a second look: it's scoped to
packages/connect/src/protocol-grpc-web/transport.ts, but its body saysFixes #1764: so merging it as-is would close this issue while@connectrpc/connect-web, the package in the report, still returnsunknown.