connect-es
connect-es copied to clipboard
Connect Web discards captured header error when body is empty
Describe the bug
Connect gRPC-web transport ignores any captured header error if message is empty.
Disclaimer: I am not too familiar with the technical details of the gRPC transport protocol, so it's very possible this is intended or legitimate behavior, but for comparison,
grpcurlgives me the correct error responses.
Connect Web does an early check to capture a gRPC error that was sent in the headers from the gRPC server. This check is done here: https://github.com/connectrpc/connect-es/blob/a08348fd45cba7a59f720842028e7512bdb0e092/packages/connect-web/src/grpc-web-transport.ts#L176-L179
In the problematic scenario, headerError is now an instance of ConnectError, with the gRPC status and message from the gRPC server. Next, Connect Web attempts to extract and parse the response body. Because the body is empty in this scenario, this is effectively a no-op.
Next, we get and validate the trailer, and we come down to this final if statement. If the read loop didn't end up producing a message, then Connect Web assumes the response to be ill-formed, and silently discards the previously detected header error. My intuition would be to check if there is a header error here first, as is done in the trailer check on the lines above.
https://github.com/connectrpc/connect-es/blob/a08348fd45cba7a59f720842028e7512bdb0e092/packages/connect-web/src/grpc-web-transport.ts#L226-L231
Issue https://github.com/connectrpc/connect-es/issues/1434 also seems relevant to bring up here, although a slightly different scenario.
To Reproduce
Have a gRPC endpoint that returns a status only, with no message body, and send a connect-web request there. I have a https://github.com/hyperium/tonic/ service that bails and returns a Err(Status). The following grpcurl invocation and response (note the content-length: 0 header) will trigger the issue.
grpcurl -H "Authorization: Bearer redacted" -proto service.proto -d '{"subject": "12312312312312123123123123123"}' -v -plaintext localhost:50051 com.redacted.Service/CreateUser
Resolved method descriptor:
rpc CreateUser ( .com.redacted.CreateUserInput ) returns ( .com.redacted.CreateUserOutput );
Request metadata to send:
authorization: Bearer redacted
Response headers received:
(empty)
Response trailers received:
content-length: 0
content-type: application/grpc
date: Mon, 28 Apr 2025 10:52:37 GMT
Sent 1 request and received 0 responses
ERROR:
Code: InvalidArgument
Message: invalid id
As noted above, I do not know the gRPC protocol well enough, but both grpcurl, and my Traefik grpcWeb middleware successfully pass this response to the client.
For the sake of it, although should not matter based on the debugging above, the following invocation is used to create the transport.
createGrpcWebTransport({
baseUrl: getGrpcEndpoint(),
useBinaryFormat: true,
fetch: async (url: RequestInfo | URL, opts?: RequestInit) => {
const headers = new Headers(opts?.headers);
if (session !== null) {
headers.set('authorization', `Bearer ${session.accessToken}`);
}
return await fetch(url, {
...opts,
headers,
});
},
});
Environment (please complete the following information):
- @connectrpc/connect-web version: 2.0.2
- @connectrpc/connect-node version: N/A
- Frontend framework and version: TanStack Start 1.117.1, and React 19 (not relevant)
- Node.js version: v22.13.1
- Browser and version: 133.0.6943.141 (Official Build) (64-bit)
Additional context Add any other context about the problem here.
For now, I have locally vendored the connect-web package and added a similar check within the if statement. Below is a diff that can be used if the same is wanted upstream.
--- third-party/connect-web/scratch 2025-04-28 19:39:45.342039526 +0900
+++ third-party/connect-web/src/grpc-web-transport.ts 2025-04-28 19:38:20.943446894 +0900
@@ -224,6 +224,12 @@
}
validateTrailer(trailer, response.headers);
if (message === undefined) {
+ // DOWNSTREAM: At this point, Connect might have discovered an error from the headers, but it does not seem
+ // to care if that exists. If there is no message at all, despite header being defined, connect will just
+ // silently ignore the header error, and chuck us a "missing message" error. Therefore, we include the
+ // following if statement.
+ if (headerError !== undefined) throw headerError;
+ // End of downstream changes.
throw new ConnectError(
"missing message",
trailer.has(headerGrpcStatus) ? Code.Unimplemented : Code.Unknown,
Thanks for the bug report and analysis, @junlarsen.
A gRPC(-Web) server can respond with a grpc-status error code in the header and omit body and trailers. It's called a "Trailers-Only" response: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
I think this case is supposed to be caught by this statement: https://github.com/connectrpc/connect-es/blob/a08348fd45cba7a59f720842028e7512bdb0e092/packages/connect-web/src/grpc-web-transport.ts#L180-L181
I'm surprised that it isn't, and that it isn't caught by tests 😬
Hi Timo, a Google search suggests that the branch you referenced would work, but browsers are misbehaving: https://developer.mozilla.org/en-US/docs/Web/API/Response/body
Note: Current browsers don't actually conform to the spec requirement to set the body property to null for responses with no body (for example, responses to HEAD requests, or 204 No Content responses).
I would assume this is where this request is failing. That being said, I'm also running connect-web from Node which faces the same problem. Perhaps its V8 not being fully spec compliant?
Maybe we could add a check for content-length in addition to the existing check? I could look into adding and writing some more tests later this week.
I played around a bit more today, and this patch seems to solve it just fine (instead of the one in my original post). If this is something of interest, I can look into submitting a PR with updated tests.
@@ -177,7 +177,10 @@ export function createGrpcWebTransport(
response.status,
response.headers,
);
- if (!response.body) {
+ // Not all browsers are spec-compliant with `null` Response bodies. For this reason, we test for an empty body
+ // using the `content-length` header. https://developer.mozilla.org/en-US/docs/Web/API/Response/body
+ const contentLength = response.headers.get('content-length');
+ if (!response.body || contentLength === '0') {
if (headerError !== undefined) throw headerError;
throw "missing response body";
}
Hey @junlarsen, I've looked into this, but cannot reproduce. Here's the script:
import {createGrpcWebTransport} from "@connectrpc/connect-web";
import {Code, createClient, ConnectError} from "@connectrpc/connect";
import {ElizaService} from "./gen/eliza_pb.js";
const transport = createGrpcWebTransport({
baseUrl: "http://localhost",
async fetch(..._args) {
const body = new ArrayBuffer(0);
const response = new Response(body, {
status: 200,
statusText: "OK",
headers: {
"Content-Type": "application/grpc-web+proto",
"Grpc-Status": Code.AlreadyExists.toString(),
"Grpc-Message": "name is taken",
"Extra-Metadata": "foobar"
},
});
if (response.body == null) {
console.log("response.body: null");
} else {
console.log("response.body: not null");
}
return Promise.resolve(response);
}
});
try {
await createClient(ElizaService, transport).say({sentence: "hi"});
} catch (e) {
const ce = ConnectError.from(e);
console.log("error:", ce.message);
console.log("metadata:", ce.metadata);
}
This script doesn't need any server. We're overriding fetch, and return a fake response with an empty body (not null), and a gRPC error status in the header.
The output is:
response.body: not null
error: [already_exists] name is taken
metadata: HeadersList {
[Symbol(headers map)]: Map(4) {
'content-type' => 'application/grpc-web+proto',
'extra-metadata' => 'foobar',
'grpc-message' => 'name is taken',
'grpc-status' => '6'
},
[Symbol(headers map sorted)]: null
}
So even if the response body isn't null, we still see the error with all data from the trailers-only response, as expected.
I can reproduce the error "missing message" by constructing an invalid gRPC-Web response:
import {createGrpcWebTransport} from "@connectrpc/connect-web";
import {Code, createClient, ConnectError} from "@connectrpc/connect";
import {ElizaService} from "./gen/eliza_pb.js";
+ import {encodeEnvelope} from "@connectrpc/connect/protocol";
const transport = createGrpcWebTransport({
baseUrl: "http://localhost",
async fetch(..._args) {
- const body = new ArrayBuffer(0);
+ const body = encodeEnvelope(0b10000000, new Uint8Array(0));
const response = new Response(body, {
status: 200,
statusText: "OK",
headers: {
"Content-Type": "application/grpc-web+proto",
"Grpc-Status": Code.AlreadyExists.toString(),
"Grpc-Message": "name is taken",
"Extra-Metadata": "foobar"
},
});
if (response.body == null) {
console.log("response.body: null");
} else {
console.log("response.body: not null");
}
return Promise.resolve(response);
}
});
try {
await createClient(ElizaService, transport).say({sentence: "hi"});
} catch (e) {
const ce = ConnectError.from(e);
console.log("error:", ce.message);
console.log("metadata:", ce.metadata);
}
Output:
response.body: not null
error: [unknown] missing message
metadata: HeadersList {
[Symbol(headers map)]: Map(0) {},
[Symbol(headers map sorted)]: null
}
This response has the "grpc-status" header field for trailers-only responses, but it also has a non-empty body, with an empty trailer frame. The response body is 5 bytes long, so a Content-Length: 0 response header would actually be very wrong (intermediaries might reject the response, cut it off, or modify the header). I don't think there's a reasonable way for us to handle this case, and we don't know for sure whether this is really what's happening here.
Closing this because the behavior for trailers-only responses is correct, regardless whether response.body is null or empty. But I'm happy to take another look if you want to put up a reproducible example with tonic.
For posterity: https://github.com/connectrpc/connect-es/issues/1434#issuecomment-3113603630