Skip to content

grpc-web transport: unary() discards a valid Grpc-Status header on trailers-only responses (fix from #1161/#1209 not ported from protocol-grpc) #1764

Open

Description

@destitutus

Describe the bug

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.

Actual: ConnectError code Unknown, message protocol error: missing output message for unary method.

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) {
    throw headerError; // never reached: trailer is an empty Headers, not undefined
  }
  throw new ConnectError("protocol error: missing trailer", ...);
}
validateTrailer(trailer, uRes.header);
if (message === undefined) {
  throw new ConnectError( // 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 response
  if (headerError) {
    throw headerError;
  }
  throw new ConnectError(...);
}

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.

Environment

  • @connectrpc/connect: 2.1.2
  • @connectrpc/connect-web: 2.1.2
  • Transport: createGrpcWebTransport

Activity

  1. destitutus commented on Aug 26, 2026

    @destitutus
    Author

    @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.

  2. ajeetdsouza commented on Sep 5, 2026

    @ajeetdsouza
    Contributor

    Hi! I had a look at this, but I'm not sure that headers + empty trailer qualifies as a Trailers-Only response.

    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.

    Could you elaborate on which proxy you're using, and how it has been configured?

  3. destitutus commented on Sep 5, 2026

    @destitutus
    Author

    @ajeetdsouza thank you for looking into it.

    It is Traefik v3's built-in grpcWeb middleware, stock configuration:

    http:
      routers:
        edu-stream:
          rule: PathRegexp(`^/stream/(.*)`)
          middlewares: [grpc-web, strip-prefix]
          service: stream
      middlewares:
        grpc-web:
          grpcWeb:
            allowOrigins: "*"
      services:
        stream:
          loadBalancer:
            servers:
              - url: "h2c://backend:19108"
    

    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.

  4. destitutus commented on Sep 6, 2026

    @destitutus
    Author

    @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

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions