Comment Envoy transforme les appels Connect du navigateur en gRPC
L’application web Transferify dialogue avec ses services back-end au moyen du protocole Connect. Les services back-end répondent en gRPC simple. Aucun des deux côtés n’a besoin de comprendre le protocole de l’autre, car Envoy, qui s’exécute devant chaque service back-end, traduit de l’un à l’autre.
Cet article explique comment Envoy décide qu’une requête est un appel Connect, comment nous n’activons la traduction que sur les routes RPC, ce que devient chaque en-tête et chaque octet à l’aller et au retour, et un tableau où Envoy et la spécification Connect actuelle ne sont pas d’accord. Chaque détail ci-dessous a été lu dans le code source d’Envoy 1.40.2 et dans la spécification Connect.
Ce qu’est le pont
envoy.filters.http.connect_grpc_bridge est un filtre HTTP d’Envoy. Le navigateur parle Connect et les services back-end répondent en gRPC ; le filtre réécrit les requêtes et les réponses de l’un à l’autre. Les services back-end n’ont donc besoin d’aucune bibliothèque Connect, et le navigateur n’a pas besoin de gRPC-Web.
Envoy traite une requête comme un appel Connect quand l’une de ces conditions est vraie :
- Le type de contenu est
application/connect+<codec>. C’est un appel en streaming. - La requête a un en-tête
Connect-Protocol-Versionet un type de contenuapplication/.... C’est un appel unaire. - La chaîne de requête contient
connect=v1. C’est un Connect GET, qu’Envoy transforme en POST.
Toute autre requête traverse le filtre sans modification.
Comment nous l’activons
Le filtre est configuré avec disabled: true pour tout le listener, puis activé route par route avec typed_per_filter_config, uniquement sur les routes RPC. Une route REST n’est jamais pontée, même quand une requête qui lui est adressée porte des en-têtes Connect ou ?connect=v1.
Les filtres s’exécutent dans cet ordre : ext_authz, puis le pont, puis le routeur. L’autorisation vérifie donc la requête Connect d’origine, et le pont ne traduit que des requêtes déjà autorisées.
Il y a une route par RPC, appariée sur le chemin exact /<package>.<Service>/<Method> et générée à partir de nos contrats protobuf. Les routes de chaque package vont vers le service back-end qui l’implémente.
Envoy qualifie cette extension d’alpha : « fonctionnelle, mais sans durée d’exploitation significative en production ». Nous l’avons accepté, et nous l’avons éprouvée sur un point d’accès en production avant d’y déplacer un autre RPC.
Pourquoi Connect et pas gRPC-Web
Les deux choix demandent un traducteur dans Envoy, connect_grpc_bridge ou grpc_web, et les deux utilisent les mêmes clients protobuf-es générés. Une seule ligne diffère dans l’application web : createConnectTransport ou createGrpcWebTransport. La différence tient à ce qui circule sur le réseau.
- Un appel Connect unaire est du HTTP ordinaire. Le corps est le message nu, et une erreur revient avec un vrai statut HTTP et un corps JSON que vous pouvez lire dans les outils de développement du navigateur. Avec gRPC-Web, chaque appel est encadré, le statut vaut toujours 200, et le résultat voyage dans des en-têtes
grpc-statusou dans une trame de trailer à l’intérieur du corps. - Les journaux d’accès et les alarmes lisent le statut HTTP. Le pont écrit le statut de l’erreur : un RPC en échec est donc journalisé et compté comme un appel REST en échec. Avec gRPC-Web, chaque RPC en échec serait journalisé comme un 200.
- CORS demande moins de configuration. gRPC-Web exige que
Grpc-Status,Grpc-MessageetGrpc-Status-Details-Binsoient exposés à la page ; Connect n’a besoin de rien de nouveau. - Connect prend aussi en charge GET (
?connect=v1), qui peut être mis en cache, pour les méthodes sans effet de bord. - connect-web utilise le protocole Connect par défaut, et sa documentation ne suggère gRPC-Web que lorsque le service back-end ne peut pas parler Connect.
Nous n’avons préparé aucun repli gRPC-Web : ce serait un second filtre, trois en-têtes CORS, une branche dans l’application web et un test que rien n’utilise. Si le pont se comportait un jour mal, le basculement coûterait environ une journée de travail sur les mêmes clients générés.
Le prix de ce choix, c’est le JSON. Le pont ne traduit pas les corps JSON : il renomme application/json en application/grpc+json et transmet les octets inchangés, et grpc-java ne lit que du protobuf binaire. L’application web utilise donc le codec binaire, et seuls les corps d’erreur sont lisibles dans les outils de développement.
La requête, champ par champ
Une requête Connect unaire devient une requête gRPC vers le service back-end, en HTTP/2 sans TLS (h2c) :
| Connect, depuis le navigateur | gRPC, vers le service back-end |
|---|---|
POST /pkg.Svc/Method | POST /pkg.Svc/Method |
Content-Type: application/proto | Content-Type: application/grpc+proto |
Connect-Protocol-Version: 1 | retiré |
Connect-Timeout-Ms: 15000 | grpc-timeout: 15000m |
Content-Encoding: gzip | grpc-encoding: gzip |
Accept-Encoding: gzip | grpc-accept-encoding: gzip |
| aucun | TE: trailers |
Content-Length | retiré |
| le message | en-tête de trame + le message |
Authorization et les autres en-têtes | les mêmes, comme métadonnées gRPC |
Le pont met tout le corps en mémoire tampon, puis place devant lui l’en-tête de trame gRPC de 5 octets : un octet d’indicateur, puis la longueur du message sur 4 octets, en big-endian. Quand le corps est compressé, l’octet d’indicateur le signale. Un délai trop long pour 8 chiffres de millisecondes passe en secondes, minutes ou heures (S, M, H).
Un Connect GET, GET /pkg.Svc/Method?connect=v1&encoding=proto&base64=1&compression=...&message=<base64url>, devient un POST. La chaîne de requête est abandonnée, message est décodé dans le corps encadré, encoding fixe le type de contenu application/grpc+<encoding>, et compression fixe grpc-encoding (identity en son absence).
La réponse, champ par champ
Une réponse gRPC réussie devient une réponse Connect :
| gRPC, depuis le service back-end | Connect, vers le navigateur |
|---|---|
statut 200 | statut 200 |
content-type: application/grpc+proto | content-type: application/proto |
| en-tête de trame + le message | le message |
grpc-encoding: gzip | Content-Encoding: gzip |
trailer grpc-status: 0 | supprimé |
autres en-têtes et trailers grpc-* | supprimés |
trailer foo: bar | en-tête trailer-foo: bar |
Le pont met toute la réponse en mémoire tampon avant de retirer l’en-tête de trame, et supprime de nouveau Content-Encoding quand la trame n’était pas compressée.
Une erreur est convertie de la même façon. grpc-java envoie un appel en échec sous forme de trailers seulement :
| Trailers gRPC, depuis le service back-end | Réponse Connect, vers le navigateur |
|---|---|
grpc-status: 7 | statut 403, "code": "permission_denied" |
grpc-message: not allowed | "message": "not allowed" |
grpc-status-details-bin: <base64> | "details": [{"type": ..., "value": "<base64>"}] |
Le type de contenu de la réponse est application/json, et son corps se lit ainsi :
{
"code": "permission_denied",
"message": "not allowed",
"details": [{
"type": "type.googleapis.com/google.rpc.ErrorInfo",
"value": "<base64>"
}]
}
type est l’URL de type exactement telle que le serveur l’a écrite. Envoy n’écrit aucun champ debug.
Il y a deux limites. Une requête unaire plus grande que la limite de tampon d’Envoy reçoit un 413 local, et une réponse plus grande qu’elle un 500. Et une réponse qui n’est pas du gRPC du tout, par exemple un 404 du service back-end pour un RPC qu’il ne sert pas, traverse sans modification ; c’est le client connect-web, et non le pont, qui transforme ce HTTP 404 en unimplemented.
La table de statuts d’Envoy est plus ancienne que celle de Connect
Pour quatre codes d’erreur, Envoy 1.40.2 répond un statut HTTP différent de celui que la spécification Connect nomme aujourd’hui :
| Code | Envoy 1.40.2 | Spécification Connect aujourd’hui |
|---|---|---|
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 a ajouté le filtre en février 2023 (envoyproxy/envoy#24836) et a copié la table de Connect telle qu’elle était alors. Connect a modifié ces quatre lignes le 11 mars 2024 (connectrpc.com#130). Deux jours plus tard, il a assoupli la règle selon laquelle le statut HTTP « doit correspondre » au code d’erreur en « devrait correspondre » (connectrpc.com#149), parce que les clients lisent le code dans le corps JSON et n’utilisent le statut HTTP que lorsqu’il n’y a pas de corps. Le mappage d’Envoy n’a pas changé depuis : il reste donc conforme à la spécification, simplement plus ancien.
Les clients ne sont pas touchés, car ils lisent le code dans le corps. Tout ce qui ne lit que le statut HTTP, comme un journal d’accès ou une alarme sur les réponses 5xx, voit un 4xx là où la spécification actuelle dit 5xx : un délai écoulé ressemble à une erreur du client.
Une passerelle peut corriger cela à deux endroits :
- Dans Envoy lui-même : la table se trouve dans une seule fonction,
statusCodeToConnectUnaryStatus, et mettre à jour ses quatre lignes est donc une petite modification du filtre. - Dans la configuration propre de la passerelle : un filtre placé avant le pont voit la réponse après que le pont l’a convertie, et peut donc lire
codedans le corps d’erreur JSON et fixer le statut HTTP d’après la table actuelle. Il doit lire le corps, car le statut seul ne permet pas de distinguercanceleddedeadline_exceeded: Envoy répond 408 dans les deux cas.
Questions fréquentes
Les services back-end ont-ils besoin d’une bibliothèque Connect ?
Non. Ils répondent en gRPC simple. Le filtre connect_grpc_bridge d’Envoy réécrit chaque requête Connect en requête gRPC à l’aller, et la réponse gRPC en réponse Connect au retour.
Pourquoi Connect et pas gRPC-Web ?
Les deux ont besoin d’un traducteur dans Envoy et utilisent les mêmes clients générés. La différence se joue sur le réseau. Un appel Connect unaire est du HTTP ordinaire : un appel qui échoue revient donc avec un vrai statut HTTP et un corps JSON que les journaux, les alarmes et les outils de développement du navigateur savent lire. Avec gRPC-Web, chaque appel répond 200 et le résultat voyage dans le corps.
Le pont peut-il traduire des corps de requête JSON ?
Non. Il renomme application/json en application/grpc+json et transmet les octets inchangés, et un serveur gRPC qui ne lit que du protobuf binaire, comme grpc-java, ne peut pas les lire. Le navigateur envoie donc du protobuf binaire, et seuls les corps d’erreur sont en JSON.
Une requête Connect GET fonctionne-t-elle à travers le pont ?
Oui. Une requête GET dont la chaîne de requête contient connect=v1 est transformée en POST. Envoy décode le message depuis la chaîne de requête vers le corps de la requête et abandonne la chaîne de requête.