Transferify
Back to blog

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.

The path of one Connect call. The browser sends a Connect request to Envoy. Inside Envoy it passes ext_authz, which checks the original request, then the Connect-gRPC bridge, which turns it into gRPC, then the router, which sends it to the backend service that serves that RPC. The answer goes back through the bridge, which turns it into a Connect answer.The path of one Connect call. The browser sends a Connect request to Envoy. Inside Envoy it passes ext_authz, which checks the original request, then the Connect-gRPC bridge, which turns it into gRPC, then the router, which sends it to the backend service that serves that RPC. The answer goes back through the bridge, which turns it into a Connect answer.
One Connect call. Authorization checks the request as the browser sent it; the bridge then turns it into gRPC for the backend, and turns the backend's gRPC answer back into Connect.

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-Version header and an application/... 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-status headers 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-Message and Grpc-Status-Details-Bin exposed 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 browsergRPC, to the backend
POST /pkg.Svc/MethodPOST /pkg.Svc/Method
Content-Type: application/protoContent-Type: application/grpc+proto
Connect-Protocol-Version: 1removed
Connect-Timeout-Ms: 15000grpc-timeout: 15000m
Content-Encoding: gzipgrpc-encoding: gzip
Accept-Encoding: gzipgrpc-accept-encoding: gzip
noneTE: trailers
Content-Lengthremoved
the messageframe header + the message
Authorization and other headersthe 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 backendConnect, to the browser
status 200status 200
content-type: application/grpc+protocontent-type: application/proto
frame header + the messagethe message
grpc-encoding: gzipContent-Encoding: gzip
trailer grpc-status: 0dropped
other grpc-* headers and trailersdropped
trailer foo: barheader 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 backendConnect answer, to the browser
grpc-status: 7status 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:

CodeEnvoy 1.40.2Connect specification today
canceled408 Request Timeout499 Client Closed Request
deadline_exceeded408 Request Timeout504 Gateway Timeout
failed_precondition412 Precondition Failed400 Bad Request
unimplemented404 Not Found501 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 code from the JSON error body and set the HTTP status from the current table. It has to read the body, because the status alone cannot tell canceled from deadline_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.

Back to top