How Envoy turns the browser's Connect calls into gRPC
The Transferify web app talks to its backends with the Connect protocol. The backends serve plain gRPC. Neither side has to understand the other's protocol, because Envoy, which runs in front of every backend, translates between them.
This post explains how Envoy decides that a request is a Connect call, how we turn the translation on for RPC routes only, what each header and byte becomes on the way in and on the way out, and one table where Envoy and today's Connect specification disagree. Every detail below was read from Envoy 1.40.2's source and from the Connect specification.
What the bridge is
envoy.filters.http.connect_grpc_bridge is an Envoy HTTP filter. The browser speaks Connect and the backends serve gRPC; the filter rewrites requests and answers between the two. So the backends need no Connect library, and the browser needs no gRPC-Web.
Envoy treats a request as a Connect call when any one of these is true:
- The content type is
application/connect+<codec>. This is a streaming call. - The request has a
Connect-Protocol-Versionheader and anapplication/...content type. This is a unary call. - The query has
connect=v1. This is a Connect GET, which Envoy turns into a POST.
Any other request passes through the filter unchanged.
How we turn it on
The filter is configured with disabled: true for the whole listener and switched on per route with typed_per_filter_config, only on RPC routes. A REST route is never bridged, even when a request to it carries Connect headers or ?connect=v1.
The filters run in this order: ext_authz, then the bridge, then the router. So authorization checks the original Connect request, and the bridge translates only requests that are already authorized.
There is one route per RPC, matched on the exact path /<package>.<Service>/<Method> and generated from our protobuf contracts. Each package's routes go to the backend service that implements it.
Envoy labels this extension alpha: "functional but has not had substantial production burn time". We accepted that, and proved it on one endpoint in production before moving any other RPC to it.
Why Connect and not gRPC-Web
Both choices need a translator in Envoy, connect_grpc_bridge or grpc_web, and both use the same generated protobuf-es clients. Only one line in the web app differs: createConnectTransport or createGrpcWebTransport. The difference is what goes over the wire.
- A unary Connect call is ordinary HTTP. The body is the bare message, and an error comes back with a real HTTP status and a JSON body you can read in the browser's devtools. With gRPC-Web every call is framed, the status is always 200, and the outcome travels in
grpc-statusheaders or in a trailer frame inside the body. - Access logs and alarms read the HTTP status. The bridge writes the error's status, so a failed RPC is logged and counted like a failed REST call. With gRPC-Web every failed RPC would be logged as 200.
- CORS needs less setup. gRPC-Web needs
Grpc-Status,Grpc-MessageandGrpc-Status-Details-Binexposed to the page; Connect needs nothing new. - Connect also supports GET (
?connect=v1), which can be cached, for methods with no side effects. - connect-web uses the Connect protocol by default, and its documentation suggests gRPC-Web only when the backend cannot speak Connect.
We kept no gRPC-Web fallback ready: it would be a second filter, three CORS headers, a branch in the web app and a test that nothing uses. If the bridge ever misbehaves, switching costs about a day of work on the same generated clients.
The cost of the choice is JSON. The bridge does not translate JSON bodies: it renames application/json to application/grpc+json and passes the bytes unchanged, and grpc-java reads only binary protobuf. So the web app uses the binary codec, and only error bodies are readable in devtools.
The request, field by field
A unary Connect request becomes a gRPC request to the backend over HTTP/2 without TLS (h2c):
| Connect, from the browser | gRPC, to the backend |
|---|---|
POST /pkg.Svc/Method | POST /pkg.Svc/Method |
Content-Type: application/proto | Content-Type: application/grpc+proto |
Connect-Protocol-Version: 1 | removed |
Connect-Timeout-Ms: 15000 | grpc-timeout: 15000m |
Content-Encoding: gzip | grpc-encoding: gzip |
Accept-Encoding: gzip | grpc-accept-encoding: gzip |
| none | TE: trailers |
Content-Length | removed |
| the message | frame header + the message |
Authorization and other headers | the same, as gRPC metadata |
The bridge buffers the whole body, then puts the 5-byte gRPC frame header in front of it: one flag byte, then the message length as 4 bytes, big-endian. When the body is compressed, the flag byte says so. A timeout too long for 8 digits of milliseconds switches to seconds, minutes or hours (S, M, H).
A Connect GET, GET /pkg.Svc/Method?connect=v1&encoding=proto&base64=1&compression=...&message=<base64url>, becomes a POST. The query is dropped, message is decoded into the framed body, encoding sets the content type application/grpc+<encoding>, and compression sets grpc-encoding (identity when it is absent).
The answer, field by field
A successful gRPC answer becomes a Connect answer:
| gRPC, from the backend | Connect, to the browser |
|---|---|
status 200 | status 200 |
content-type: application/grpc+proto | content-type: application/proto |
| frame header + the message | the message |
grpc-encoding: gzip | Content-Encoding: gzip |
trailer grpc-status: 0 | dropped |
other grpc-* headers and trailers | dropped |
trailer foo: bar | header trailer-foo: bar |
The bridge buffers the whole answer before it removes the frame header, and drops Content-Encoding again when the frame was not compressed.
An error is converted the same way. grpc-java sends a failed call as trailers only:
| gRPC trailers, from the backend | Connect answer, to the browser |
|---|---|
grpc-status: 7 | status 403, "code": "permission_denied" |
grpc-message: not allowed | "message": "not allowed" |
grpc-status-details-bin: <base64> | "details": [{"type": ..., "value": "<base64>"}] |
The answer's content type is application/json, and its body reads:
{
"code": "permission_denied",
"message": "not allowed",
"details": [{
"type": "type.googleapis.com/google.rpc.ErrorInfo",
"value": "<base64>"
}]
}
type is the type URL exactly as the server wrote it. Envoy writes no debug field.
There are two limits. A unary request larger than Envoy's buffer limit gets a local 413, and an answer larger than it a 500. And an answer that is not gRPC at all, for example a 404 from the backend for an RPC it does not serve, passes through unchanged; the connect-web client, not the bridge, turns that HTTP 404 into unimplemented.
Envoy's status table is older than Connect's
For four error codes, Envoy 1.40.2 answers a different HTTP status than the Connect specification names today:
| Code | Envoy 1.40.2 | Connect specification today |
|---|---|---|
canceled | 408 Request Timeout | 499 Client Closed Request |
deadline_exceeded | 408 Request Timeout | 504 Gateway Timeout |
failed_precondition | 412 Precondition Failed | 400 Bad Request |
unimplemented | 404 Not Found | 501 Not Implemented |
Envoy added the filter in February 2023 (envoyproxy/envoy#24836) and copied Connect's table as it was then. Connect changed these four rows on 11 March 2024 (connectrpc.com#130). Two days later it relaxed the rule that the HTTP status "must match" the error code to "should match" (connectrpc.com#149), because clients read the code from the JSON body and use the HTTP status only when there is no body. Envoy's mapping has not changed since, so it is still within the specification, just older.
Clients are not affected, because they read the code from the body. Anything that reads only the HTTP status, such as an access log or an alarm on 5xx answers, sees a 4xx where today's specification says 5xx: a deadline that ran out looks like a client error.
A gateway can correct this in two places:
- In Envoy itself: the table lives in one function,
statusCodeToConnectUnaryStatus, so updating its four rows is a small change to the filter. - In the gateway's own configuration: a filter listed before the bridge sees the answer after the bridge has converted it, so it can read
codefrom the JSON error body and set the HTTP status from the current table. It has to read the body, because the status alone cannot tellcanceledfromdeadline_exceeded: Envoy answers 408 for both.
Frequently asked questions
Do the backends need a Connect library?
No. They serve plain gRPC. Envoy's connect_grpc_bridge filter rewrites each Connect request into a gRPC request on the way in, and the gRPC answer into a Connect answer on the way out.
Why Connect and not gRPC-Web?
Both need a translator in Envoy and both use the same generated clients. The difference is on the wire. A unary Connect call is ordinary HTTP, so a failed call comes back with a real HTTP status and a JSON body that logs, alarms and browser devtools can read. With gRPC-Web every call answers 200 and the outcome travels inside the body.
Can the bridge translate JSON request bodies?
No. It renames application/json to application/grpc+json and passes the bytes unchanged, and a gRPC server that reads only binary protobuf, such as grpc-java, cannot read them. So the browser sends binary protobuf, and only error bodies are JSON.
Does a Connect GET request work through the bridge?
Yes. A GET whose query has connect=v1 is turned into a POST. Envoy decodes the message from the query into the request body and drops the query.