Skip to content

feat(volo-grpc): derive HTTP/2 :authority from the callee endpoint on a shared core transport pool - #670

Open
tonyvelichko wants to merge 3 commits into
cloudwego:mainfrom
tonyvelichko:feat/grpc-authority-from-endpoint
Open

tonyvelichko wants to merge 3 commits into
cloudwego:mainfrom
tonyvelichko:feat/grpc-authority-from-endpoint

Conversation

@tonyvelichko

Copy link
Copy Markdown

Motivation

volo-grpc clients cannot be put behind a TLS-terminating proxy or ingress that routes on the HTTP/2 :authority (virtual host). The client transport used hyper-util's legacy Client, whose pool key, dial target and wire :authority are all the same string — the request URI's authority — so build_uri() had to put the resolved ip:port there, and every call arrived at the proxy as :authority: 10.42.3.17:50051. HTTP/2 derives :authority solely from the URI (no Host header fallback), so this could not be fixed at the header level.

Fixing it inside hyper's pool is not possible either: giving the URI a host name either forces DNS resolution back into the connector (bypassing volo's Discover/LoadBalance) or collapses load balancing to one connection per host name, because hyper pools by authority.

Solution

Two commits, one per crate:

feat(volo): add generic transport pool — introduces volo::pool, a generic transport pool for the protocol crates, ported from volo-thrift's pool and generalized:

  • Pool<K, T: Poolable> keyed by peer (usually net::Address), with Mode::Unique (exclusive; handed back via Pooled::reuse) and Mode::Shared (multiplexed; one transport per key handed out as clones, concurrent connects deduplicated).
  • New transports come from any motore::UnaryService<K>; errors surface as pool::Error<E>, so each crate maps them to its own error type (no thrift TransportException coupling).
  • The connect runs as its own task, so a caller giving up (e.g. rpc timeout) no longer cancels the transport other waiters are queued on.
  • The idle-eviction task starts lazily on first get, so Pool::new is safe outside a runtime (e.g. in a LazyLock).

volo-thrift and volo-http still carry their own copies of this design; migrating them is left as a follow-up to keep this PR reviewable.

feat(volo-grpc): derive HTTP/2 :authority from the callee endpoint — the transport keeps HTTP/2 connections in volo::pool::Pool<Address, _> (shared mode, 90s idle timeout, matching hyper's default) keyed by the load-balanced address, and builds the request URI from the callee Endpoint. :authority is chosen by the first rule that applies:

  1. an explicit volo_grpc::client::Authority faststr tag on the callee (per call via CallOpt::callee_faststr_tags, or from a layer);
  2. the TLS server name (SNI), plus the dialed port unless it is 443 — SNI and :authority always agree;
  3. the callee service_name when it is a valid authority — the host:port given to the DNS resolver, or a logical service name with a custom Discover (what grpc-go and tonic send);
  4. the dialed address.

:scheme is now https when TLS is configured. Load balancing keeps one connection per instance regardless of :authority. A request that hyper hands back untouched because the connection died underneath it is retried once on a fresh connection. hex and hyper-util's client-legacy feature are no longer needed.

Usage for the motivating case needs no new configuration:

let client = GreeterClientBuilder::new("grpc.internal.example.com:50051")
    .tls_config(ClientTlsConfig::new("grpc.internal.example.com", connector))
    .build();
// SNI: grpc.internal.example.com, :authority: grpc.internal.example.com:50051, :scheme: https

Behaviour change

Plaintext clients whose service_name is a logical name (e.g. hello) now send that name as :authority instead of 127.0.0.1:8080. gRPC servers ignore :authority unless they route on it.

Release note

volo must be released before volo-grpc (new public module volo::pool).

Tests

  • volo::pool: 10 unit tests — unique reuse/discard, shared dedupe of concurrent connects, the connect surviving the first caller's cancellation, closed-transport replacement, connect-error reporting, idle eviction, idle task stopping with the pool.
  • volo-grpc transport: unit tests for every :authority rule (incl. invalid tag/service names falling through), and wire-level tests against a real hyper HTTP/2 server asserting the :authority received, connection reuse per address, distinct connections per address, 32 concurrent first calls → 1 handshake, and the Authority tag on the wire.
  • CI matrix locally: cargo +nightly fmt --all --check; cargo clippy --deny warnings for volo (default, rustls-aws-lc-rs, rustls-ring), volo-grpc (no-default, rustls, native-tls, grpc-web) and --all; cargo test -p volo, cargo test -p volo-grpc --features rustls; hello, hello-tls and loadbalance gRPC examples run end-to-end.

Docs: docs/grpc-client-transport.md describes the :authority rules, the pooling behaviour, and how a protocol crate adopts volo::pool.

Introduce `volo::pool`, a generic transport pool for the protocol crates,
ported from volo-thrift's pool and generalized:

- `Pool<K, T: Poolable>` keyed by peer (usually `net::Address`), with
  `Mode::Unique` (exclusive, handed back via `Pooled::reuse`) and
  `Mode::Shared` (multiplexed, one transport per key handed out as clones,
  concurrent connects deduplicated)
- new transports come from any `motore::UnaryService<K>`; errors surface
  as `pool::Error<E>` so each crate maps them to its own error type
- the connect runs as its own task, so a caller giving up (rpc timeout)
  no longer cancels the transport other waiters are queued on
- the idle-eviction task starts lazily on first `get`, making `Pool::new`
  safe outside a runtime

volo-grpc switches to it in the next commit; volo-thrift and volo-http
still carry their own copies and are to be migrated.
The client transport used hyper-util's legacy `Client`, whose pool key,
dial target and `:authority` are all the request URI's authority, so the
wire `:authority` was always the resolved `ip:port`. That breaks any
TLS-terminating proxy or ingress routing on virtual host.

Manage HTTP/2 connections in `volo::pool::Pool<Address, _>` (shared mode,
90s idle timeout) keyed by the load-balanced address, and build the
request URI from the callee `Endpoint` instead:

  0. an explicit `client::Authority` faststr tag on the callee, if set
     (per call via `CallOpt::callee_faststr_tags`, or from a layer)
  1. the TLS server name (SNI), plus the dialed port unless it is 443
  2. the callee `service_name` when it is a valid authority (the
     `host:port` given to the DNS resolver, or a logical service name)
  3. the dialed address

`:scheme` is now `https` when TLS is configured. Load balancing keeps one
connection per instance regardless of `:authority`. A request handed
back untouched by hyper because the connection died is retried once.

Behaviour change: plaintext clients with a logical `service_name` now
send that name as `:authority` instead of `ip:port`.

Drops the `hex` dependency and hyper-util's `client-legacy` feature.
Adds docs/grpc-client-transport.md.
@CLAassistant

CLAassistant commented Aug 31, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants