Transferify
Torna al blog

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.

Il percorso di una chiamata Connect. Il browser invia una richiesta Connect a Envoy. All’interno di Envoy passa da ext_authz, che controlla la richiesta originale, poi dal bridge Connect-gRPC, che la trasforma in gRPC, poi dal router, che la invia al servizio backend che serve quella RPC. La risposta torna indietro attraverso il bridge, che la trasforma in una risposta Connect.Il percorso di una chiamata Connect. Il browser invia una richiesta Connect a Envoy. All’interno di Envoy passa da ext_authz, che controlla la richiesta originale, poi dal bridge Connect-gRPC, che la trasforma in gRPC, poi dal router, che la invia al servizio backend che serve quella RPC. La risposta torna indietro attraverso il bridge, che la trasforma in una risposta Connect.
Una chiamata Connect. L’autorizzazione controlla la richiesta così come l’ha inviata il browser; il bridge la trasforma poi in gRPC per il backend e riconverte in Connect la risposta gRPC del backend.

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-Version e un content type application/.... È 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-status o 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-Message e Grpc-Status-Details-Bin siano 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 browsergRPC, verso il backend
POST /pkg.Svc/MethodPOST /pkg.Svc/Method
Content-Type: application/protoContent-Type: application/grpc+proto
Connect-Protocol-Version: 1rimossa
Connect-Timeout-Ms: 15000grpc-timeout: 15000m
Content-Encoding: gzipgrpc-encoding: gzip
Accept-Encoding: gzipgrpc-accept-encoding: gzip
nessunaTE: trailers
Content-Lengthrimossa
il messaggiointestazione del frame + il messaggio
Authorization e altre intestazionile 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 backendConnect, verso il browser
stato 200stato 200
content-type: application/grpc+protocontent-type: application/proto
intestazione del frame + il messaggioil messaggio
grpc-encoding: gzipContent-Encoding: gzip
trailer grpc-status: 0eliminato
altre intestazioni e trailer grpc-*eliminati
trailer foo: barintestazione 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 backendRisposta Connect, verso il browser
grpc-status: 7stato 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:

CodiceEnvoy 1.40.2Specifica Connect oggi
canceled408 Request Timeout499 Client Closed Request
deadline_exceeded408 Request Timeout504 Gateway Timeout
failed_precondition412 Precondition Failed400 Bad Request
unimplemented404 Not Found501 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 code dal corpo JSON dell’errore e impostare lo stato HTTP secondo la tabella attuale. Deve leggere il corpo, perché lo stato da solo non distingue canceled da deadline_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.

Torna su