Come Envoy trasforma in gRPC le chiamate Connect del browser
La web app di Transferify dialoga con i propri backend tramite il protocollo Connect. I backend servono gRPC semplice. Nessuna delle due parti deve capire il protocollo dell’altra, perché Envoy, che gira davanti a ogni backend, traduce tra i due.
Questo articolo spiega come Envoy decide che una richiesta è una chiamata Connect, come attiviamo la traduzione solo per le route RPC, in che cosa si trasforma ogni intestazione e ogni byte in ingresso e in uscita, e una tabella in cui Envoy e l’attuale specifica Connect non concordano. Ogni dettaglio qui sotto è stato letto dal codice sorgente di Envoy 1.40.2 e dalla specifica Connect.
Che cos’è il bridge
envoy.filters.http.connect_grpc_bridge è un filtro HTTP di Envoy. Il browser parla Connect e i backend servono gRPC; il filtro riscrive richieste e risposte tra i due. Perciò i backend non hanno bisogno di una libreria Connect e il browser non ha bisogno di gRPC-Web.
Envoy considera una richiesta una chiamata Connect quando è vera una qualsiasi di queste condizioni:
- Il content type è
application/connect+<codec>. È una chiamata in streaming. - La richiesta ha un’intestazione
Connect-Protocol-Versione un content typeapplication/.... È una chiamata unaria. - La query contiene
connect=v1. È una Connect GET, che Envoy trasforma in POST.
Qualsiasi altra richiesta attraversa il filtro senza modifiche.
Come lo attiviamo
Il filtro è configurato con disabled: true per l’intero listener e attivato route per route con typed_per_filter_config, solo sulle route RPC. Una route REST non passa mai dal bridge, nemmeno quando una richiesta verso di essa porta intestazioni Connect o ?connect=v1.
I filtri vengono eseguiti in quest’ordine: ext_authz, poi il bridge, poi il router. Così l’autorizzazione controlla la richiesta Connect originale e il bridge traduce soltanto richieste già autorizzate.
C’è una route per ogni RPC, associata al percorso esatto /<package>.<Service>/<Method> e generata dai nostri contratti protobuf. Le route di ciascun package vanno al servizio backend che lo implementa.
Envoy etichetta questa estensione come alpha: «functional but has not had substantial production burn time». Lo abbiamo accettato e lo abbiamo provato su un solo endpoint in produzione prima di spostare su di essa qualsiasi altra RPC.
Perché Connect e non gRPC-Web
Entrambe le scelte richiedono un traduttore in Envoy, connect_grpc_bridge oppure grpc_web, ed entrambe usano gli stessi client protobuf-es generati. Cambia una sola riga nella web app: createConnectTransport oppure createGrpcWebTransport. La differenza è ciò che passa sul filo.
- Una chiamata Connect unaria è semplice HTTP. Il corpo è il messaggio puro e un errore torna con un vero stato HTTP e un corpo JSON leggibile negli strumenti per sviluppatori del browser. Con gRPC-Web ogni chiamata è incorniciata, lo stato è sempre 200 e l’esito viaggia nelle intestazioni
grpc-statuso in un frame di trailer dentro il corpo. - I log di accesso e gli allarmi leggono lo stato HTTP. Il bridge scrive lo stato dell’errore, perciò una RPC fallita viene registrata e contata come una chiamata REST fallita. Con gRPC-Web ogni RPC fallita verrebbe registrata come 200.
- CORS richiede meno configurazione. gRPC-Web richiede che
Grpc-Status,Grpc-MessageeGrpc-Status-Details-Binsiano esposti alla pagina; Connect non richiede nulla di nuovo. - Connect supporta anche GET (
?connect=v1), che può essere messa in cache, per i metodi senza effetti collaterali. - connect-web usa per impostazione predefinita il protocollo Connect, e la sua documentazione suggerisce gRPC-Web solo quando il backend non sa parlare Connect.
Non abbiamo tenuto pronto alcun ripiego su gRPC-Web: sarebbe un secondo filtro, tre intestazioni CORS, un ramo nella web app e un test che nessuno usa. Se il bridge dovesse mai comportarsi male, il passaggio costa circa una giornata di lavoro sugli stessi client generati.
Il prezzo di questa scelta è JSON. Il bridge non traduce i corpi JSON: rinomina application/json in application/grpc+json e lascia invariati i byte, e grpc-java legge solo protobuf binario. Perciò la web app usa il codec binario e solo i corpi degli errori sono leggibili negli strumenti per sviluppatori.
La richiesta, campo per campo
Una richiesta Connect unaria diventa una richiesta gRPC verso il backend su HTTP/2 senza TLS (h2c):
| Connect, dal browser | gRPC, verso il backend |
|---|---|
POST /pkg.Svc/Method | POST /pkg.Svc/Method |
Content-Type: application/proto | Content-Type: application/grpc+proto |
Connect-Protocol-Version: 1 | rimossa |
Connect-Timeout-Ms: 15000 | grpc-timeout: 15000m |
Content-Encoding: gzip | grpc-encoding: gzip |
Accept-Encoding: gzip | grpc-accept-encoding: gzip |
| nessuna | TE: trailers |
Content-Length | rimossa |
| il messaggio | intestazione del frame + il messaggio |
Authorization e altre intestazioni | le stesse, come metadati gRPC |
Il bridge mette in buffer l’intero corpo, poi antepone l’intestazione di frame gRPC di 5 byte: un byte di flag, poi la lunghezza del messaggio su 4 byte, big-endian. Quando il corpo è compresso, il byte di flag lo indica. Un timeout troppo lungo per 8 cifre di millisecondi passa a secondi, minuti od ore (S, M, H).
Una Connect GET, GET /pkg.Svc/Method?connect=v1&encoding=proto&base64=1&compression=...&message=<base64url>, diventa una POST. La query viene eliminata, message viene decodificato nel corpo incorniciato, encoding imposta il content type application/grpc+<encoding> e compression imposta grpc-encoding (identity quando è assente).
La risposta, campo per campo
Una risposta gRPC riuscita diventa una risposta Connect:
| gRPC, dal backend | Connect, verso il browser |
|---|---|
stato 200 | stato 200 |
content-type: application/grpc+proto | content-type: application/proto |
| intestazione del frame + il messaggio | il messaggio |
grpc-encoding: gzip | Content-Encoding: gzip |
trailer grpc-status: 0 | eliminato |
altre intestazioni e trailer grpc-* | eliminati |
trailer foo: bar | intestazione trailer-foo: bar |
Il bridge mette in buffer l’intera risposta prima di rimuovere l’intestazione del frame ed elimina di nuovo Content-Encoding quando il frame non era compresso.
Un errore viene convertito allo stesso modo. grpc-java invia una chiamata fallita come soli trailer:
| Trailer gRPC, dal backend | Risposta Connect, verso il browser |
|---|---|
grpc-status: 7 | stato 403, "code": "permission_denied" |
grpc-message: not allowed | "message": "not allowed" |
grpc-status-details-bin: <base64> | "details": [{"type": ..., "value": "<base64>"}] |
Il content type della risposta è application/json e il suo corpo è il seguente:
{
"code": "permission_denied",
"message": "not allowed",
"details": [{
"type": "type.googleapis.com/google.rpc.ErrorInfo",
"value": "<base64>"
}]
}
type è l’URL del tipo esattamente come l’ha scritto il server. Envoy non scrive alcun campo debug.
Ci sono due limiti. Una richiesta unaria più grande del limite del buffer di Envoy riceve un 413 locale, e una risposta più grande un 500. E una risposta che non è affatto gRPC, per esempio un 404 del backend per una RPC che non serve, passa senza modifiche; è il client connect-web, non il bridge, a trasformare quel 404 HTTP in unimplemented.
La tabella degli stati di Envoy è più vecchia di quella di Connect
Per quattro codici di errore, Envoy 1.40.2 risponde con uno stato HTTP diverso da quello che oggi indica la specifica Connect:
| Codice | Envoy 1.40.2 | Specifica Connect oggi |
|---|---|---|
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 ha aggiunto il filtro a febbraio 2023 (envoyproxy/envoy#24836) e ha copiato la tabella di Connect com’era allora. Connect ha modificato queste quattro righe l’11 marzo 2024 (connectrpc.com#130). Due giorni dopo ha attenuato la regola secondo cui lo stato HTTP «must match» il codice di errore in «should match» (connectrpc.com#149), perché i client leggono il codice dal corpo JSON e usano lo stato HTTP solo quando non c’è un corpo. La mappatura di Envoy non è cambiata da allora, quindi rientra ancora nella specifica, solo più vecchia.
I client non ne risentono, perché leggono il codice dal corpo. Tutto ciò che legge solo lo stato HTTP, come un log di accesso o un allarme sulle risposte 5xx, vede un 4xx dove la specifica attuale dice 5xx: una scadenza esaurita sembra un errore del client.
Un gateway può correggere questo aspetto in due punti:
- In Envoy stesso: la tabella si trova in una sola funzione,
statusCodeToConnectUnaryStatus, perciò aggiornarne le quattro righe è una piccola modifica al filtro. - Nella configurazione del gateway stesso: un filtro elencato prima del bridge vede la risposta dopo che il bridge l’ha convertita, quindi può leggere
codedal corpo JSON dell’errore e impostare lo stato HTTP secondo la tabella attuale. Deve leggere il corpo, perché lo stato da solo non distinguecanceleddadeadline_exceeded, dato che Envoy risponde 408 per entrambi.
Domande frequenti
I backend hanno bisogno di una libreria Connect?
No. Servono gRPC semplice. Il filtro connect_grpc_bridge di Envoy riscrive ogni richiesta Connect in una richiesta gRPC in ingresso e la risposta gRPC in una risposta Connect in uscita.
Perché Connect e non gRPC-Web?
Entrambi richiedono un traduttore in Envoy ed entrambi usano gli stessi client generati. La differenza sta sul filo. Una chiamata Connect unaria è semplice HTTP, quindi una chiamata fallita torna con un vero stato HTTP e un corpo JSON che log, allarmi e strumenti per sviluppatori del browser sanno leggere. Con gRPC-Web ogni chiamata risponde 200 e l’esito viaggia nel corpo.
Il bridge può tradurre i corpi delle richieste JSON?
No. Rinomina application/json in application/grpc+json e lascia invariati i byte, e un server gRPC che legge solo protobuf binario, come grpc-java, non riesce a leggerli. Perciò il browser invia protobuf binario e solo i corpi degli errori sono JSON.
Una richiesta Connect GET funziona attraverso il bridge?
Sì. Una GET la cui query contiene connect=v1 viene trasformata in POST. Envoy decodifica il messaggio dalla query nel corpo della richiesta ed elimina la query.