dwara_core/dataplane/
upstream_h3.rs

1//! HTTP/3 (QUIC) upstream transport (DW-108).
2//!
3//! The mirror of the DW-088 H3 *ingress* listener: an H3 *egress*
4//! connector that dials upstream endpoints over QUIC and speaks
5//! HTTP/3 on bidirectional QUIC streams. A route whose upstream is
6//! configured `protocol: h3` dispatches through here instead of the
7//! TCP/TLS pooled client in [`super::upstream`].
8//!
9//! ## Transport model
10//!
11//! QUIC multiplexes many streams over one connection, so the pool
12//! shape is inverted relative to HTTP/1.1: a QUIC *connection* is the
13//! pooled resource, and each request opens a fresh bidirectional stream
14//! *within* it. [`QuicStreamPool`] keeps a bounded set of QUIC
15//! connections per endpoint address, hands out a cheaply cloneable
16//! [`h3::client::SendRequest`] handle per request (one stream per
17//! `send_request` call), and reaps connections idle past
18//! `QuicStreamPool::idle_timeout`.
19//!
20//! ## TLS
21//!
22//! QUIC mandates TLS 1.3, so every H3 upstream negotiates TLS with
23//! ALPN `h3`. The trust roots are the SAME ones the pooled https
24//! connector uses (#121): the Mozilla webpki public set by default, or
25//! the upstream's `trusted_ca_file` bundle when configured. The shared
26//! rustls client-config shape lives in [`crate::security::tls`]
27//! ([`https_h3_client_config`]) so the connector and the QUIC active
28//! health probe can never disagree about trust.
29//!
30//! ## 0-RTT
31//!
32//! Deliberately NOT used for upstream dialing: 0-RTT early data is
33//! replayable, and a replayed non-idempotent upstream request is a
34//! footgun the gateway must not expose by default. The rustls client
35//! config disables early data ([`https_h3_client_config`]).
36//!
37//! ## Response streaming (PERF-10, #243)
38//!
39//! The H3 transport supports two response modes:
40//! - [`h3_request`]: buffers the full response body (the original v1
41//!   behavior; kept for compatibility and simple use cases).
42//! - [`h3_request_streaming`]: streams the response body through a
43//!   tokio channel as h3 `recv_data` chunks arrive, matching the
44//!   h1/h2 zero-buffer guarantee. The driver task reads `recv_data`
45//!   and sends each chunk through a bounded channel (capacity 1) so
46//!   backpressure flows from the consumer to the upstream. The caller
47//!   wraps the receiver in `UpstreamBody::from_h3_streaming` to get
48//!   a `hyper::Body`-compatible streaming body with the
49//!   idle/deadline/health knobs.
50//!
51//! The request body is still sent as one `DATA` frame (the proxy
52//! already buffers request bodies for retries); streaming request
53//! bodies are a separate enhancement.
54//!
55//! Everything in this module is compiled into the OSS build; H3
56//! upstreams are always available.
57
58use std::collections::HashMap;
59use std::net::SocketAddr;
60use std::sync::atomic::Ordering;
61use std::sync::Arc;
62use std::time::{Duration, Instant};
63
64use bytes::{Buf, Bytes};
65// #272: parking_lot::Mutex for the connection-pool critical sections.
66// Brief (HashMap ops, never across an await) but touched on every H3
67// request; parking_lot's adaptive spinning avoids OS-level thread
68// blocking under moderate contention.
69use h3::client::{self, SendRequest};
70use h3_quinn::OpenStreams;
71use http::{HeaderMap, Response, StatusCode};
72use parking_lot::Mutex;
73use quinn::crypto::rustls::QuicClientConfig;
74use quinn::{ClientConfig, Endpoint};
75use tokio::task::JoinHandle;
76use tokio::time::timeout;
77
78use crate::dataplane::upstream::{UpstreamError, UpstreamStats};
79use crate::security::tls::https_h3_client_config;
80
81/// Default maximum QUIC connections per endpoint address when the
82/// upstream's `connection_cap` is absent. Lower than the TCP/TLS default
83/// (64): one QUIC connection carries many streams, so a handful of
84/// connections saturates concurrency without the per-stream head-of-line
85/// blocking that makes TCP pools need many connections.
86pub const DEFAULT_H3_CONNECTION_CAP: u32 = 8;
87
88/// Default idle timeout for a pooled QUIC connection (no streams opened
89/// through it for this long => close and reap). QUIC's own keep-alive is
90/// not configured here; the sweep is the reaper.
91pub const DEFAULT_H3_IDLE_TIMEOUT_MS: u64 = 30_000;
92
93/// Errors specific to the H3/QUIC upstream transport. Mapped onto the
94/// shared [`UpstreamError`] by [`H3UpstreamHandle::send`] so the proxy,
95/// breaker, and health layers stay transport-agnostic.
96#[derive(Debug)]
97pub enum H3Error {
98    /// QUIC connection attempt failed (handshake, transport, timeout).
99    Connect(quinn::ConnectionError),
100    /// Building the h3 client over the QUIC connection failed.
101    H3(h3::error::ConnectionError),
102    /// An HTTP/3 stream-level error (framing, QPACK, ...).
103    Stream(h3::error::StreamError),
104    /// The QUIC endpoint could not be bound (configuration error; only
105    /// reachable from a misconfigured pool build).
106    Endpoint(std::io::Error),
107    /// The rustls client config could not be turned into a QUIC client
108    /// config.
109    Crypto(String),
110}
111
112impl std::fmt::Display for H3Error {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            H3Error::Connect(e) => write!(f, "h3 upstream QUIC connect failed: {e}"),
116            H3Error::H3(e) => write!(f, "h3 upstream connection setup failed: {e}"),
117            H3Error::Stream(e) => write!(f, "h3 upstream stream error: {e}"),
118            H3Error::Endpoint(e) => write!(f, "h3 upstream endpoint bind failed: {e}"),
119            H3Error::Crypto(e) => write!(f, "h3 upstream QUIC crypto config failed: {e}"),
120        }
121    }
122}
123
124impl std::error::Error for H3Error {}
125
126impl From<H3Error> for UpstreamError {
127    fn from(e: H3Error) -> Self {
128        match e {
129            // A connect-time failure maps to the same transport-failure
130            // classification the TCP/TLS connector uses; the proxy and
131            // breaker treat it as a retryable transport error.
132            H3Error::Connect(quinn::ConnectionError::TimedOut) => UpstreamError::ConnectTimeout {
133                after: Duration::ZERO,
134            },
135            H3Error::Connect(e) => UpstreamError::Io(std::io::Error::other(e.to_string())),
136            H3Error::H3(e) => UpstreamError::Io(std::io::Error::other(e.to_string())),
137            H3Error::Stream(e) => UpstreamError::Io(std::io::Error::other(e.to_string())),
138            H3Error::Endpoint(e) => UpstreamError::Io(e),
139            H3Error::Crypto(e) => UpstreamError::Io(std::io::Error::other(e)),
140        }
141    }
142}
143
144impl From<UpstreamError> for H3Error {
145    fn from(e: UpstreamError) -> Self {
146        match e {
147            UpstreamError::Io(io) => H3Error::Endpoint(io),
148            other => H3Error::Endpoint(std::io::Error::other(other.to_string())),
149        }
150    }
151}
152
153/// Resolve `address:port` to one `SocketAddr` via `getaddrinfo` on the
154/// blocking pool (IP literals short-circuit, the same path
155/// `tokio::net::lookup_host` takes). The FIRST resolved address dials;
156/// happy-eyeballs racing across QUIC addresses is a follow-up (QUIC
157/// connection migration makes it less pressing than for TCP). Mirrors
158/// the legacy connector's resolve step so an H3 upstream with a
159/// hostname endpoint behaves like its TCP/TLS counterpart.
160async fn resolve_one(address: &str, port: u16) -> Result<SocketAddr, UpstreamError> {
161    tokio::net::lookup_host((address, port))
162        .await
163        .map_err(UpstreamError::Io)?
164        .next()
165        .ok_or_else(|| {
166            UpstreamError::Io(std::io::Error::new(
167                std::io::ErrorKind::AddrNotAvailable,
168                format!("'{address}:{port}' resolved to no addresses"),
169            ))
170        })
171}
172
173/// One pooled QUIC connection to an endpoint: a cloneable h3 request
174/// sender (one stream per `send_request` call), the last-used clock for
175/// the idle sweep, and the driver task that pumps h3 control frames until
176/// the connection closes (the `SendRequest` clones keep the QUIC
177/// connection alive; when the last one drops the connection drains and
178/// the driver resolves).
179struct ConnEntry {
180    send_request: SendRequest<OpenStreams, Bytes>,
181    last_used: Instant,
182    /// Drives `h3::client::Connection::wait_idle`; its completion signals
183    /// the connection is closed. `is_finished()` is the liveness probe
184    /// used by [`QuicStreamPool::acquire`] to skip dead entries without
185    /// dialing through them.
186    driver: JoinHandle<()>,
187}
188
189impl ConnEntry {
190    fn is_live(&self) -> bool {
191        !self.driver.is_finished()
192    }
193}
194
195/// QUIC stream-aware connection pool: a bounded set of QUIC connections
196/// per endpoint address, each carrying many H3 streams. One
197/// [`QuicStreamPool`] serves one upstream (the per-upstream connection
198/// cap and trust roots are fixed at build time); endpoint addresses are
199/// the inner key so a multi-endpoint upstream shares one pool.
200///
201/// The pool binds ONE quinn client endpoint (an ephemeral `0.0.0.0:0`
202/// UDP socket) for all dials; quinn multiplexes connections over it.
203pub struct QuicStreamPool {
204    endpoint: Endpoint,
205    tls: Arc<rustls::ClientConfig>,
206    connect_timeout: Duration,
207    max_conns_per_endpoint: usize,
208    idle_timeout: Duration,
209    conns: Mutex<HashMap<SocketAddr, Vec<ConnEntry>>>,
210    stats: Arc<UpstreamStats>,
211}
212
213impl QuicStreamPool {
214    /// Build a pool for one upstream's trust roots and connection cap.
215    /// `roots` is the trust store the QUIC handshake verifies the
216    /// upstream's server certificate against (webpki by default, the
217    /// upstream's `trusted_ca_file` bundle when configured). Binds the
218    /// quinn client endpoint to an ephemeral port.
219    pub fn new(
220        roots: rustls::RootCertStore,
221        connect_timeout: Duration,
222        max_conns_per_endpoint: usize,
223        idle_timeout: Duration,
224        stats: Arc<UpstreamStats>,
225    ) -> Result<Self, H3Error> {
226        let tls = Arc::new(https_h3_client_config(roots));
227        let quic_client_config = QuicClientConfig::try_from(Arc::clone(&tls))
228            .map_err(|e| H3Error::Crypto(e.to_string()))?;
229        let mut client_config = ClientConfig::new(Arc::new(quic_client_config));
230        // Keep the client config's transport defaults; quinn's defaults
231        // are sane for short-lived request streams. A follow-up can wire
232        // max_concurrent_bidi_streams from the connection cap.
233        let _ = &mut client_config;
234        let mut endpoint = Endpoint::client(
235            "0.0.0.0:0"
236                .parse()
237                .expect("0.0.0.0:0 is a valid socket addr"),
238        )
239        .map_err(H3Error::Endpoint)?;
240        endpoint.set_default_client_config(client_config);
241        Ok(QuicStreamPool {
242            endpoint,
243            tls,
244            connect_timeout,
245            max_conns_per_endpoint: max_conns_per_endpoint.max(1),
246            idle_timeout,
247            conns: Mutex::new(HashMap::new()),
248            stats,
249        })
250    }
251
252    /// The rustls client config this pool verifies against (shared with
253    /// the QUIC active health probe so a probe and a proxied request can
254    /// never disagree about who to trust).
255    pub fn tls_config(&self) -> &Arc<rustls::ClientConfig> {
256        &self.tls
257    }
258
259    /// Dial one QUIC connection to `addr` and wrap it in h3. The driver
260    /// task pumps h3 control frames; the returned entry keeps a
261    /// cloneable `SendRequest` for opening streams.
262    async fn dial(&self, addr: SocketAddr, server_name: &str) -> Result<ConnEntry, H3Error> {
263        let quinn_conn = match timeout(self.connect_timeout, async {
264            self.endpoint
265                .connect(addr, server_name)
266                .map_err(|e| H3Error::Endpoint(std::io::Error::other(e.to_string())))?
267                .await
268                .map_err(H3Error::Connect)
269        })
270        .await
271        {
272            Ok(Ok(c)) => c,
273            Ok(Err(e)) => return Err(e),
274            Err(_elapsed) => {
275                return Err(H3Error::Connect(quinn::ConnectionError::TimedOut));
276            }
277        };
278        self.stats
279            .connections_opened
280            .fetch_add(1, Ordering::Relaxed);
281        let (h3_conn, send_request) = client::new(h3_quinn::Connection::new(quinn_conn))
282            .await
283            .map_err(H3Error::H3)?;
284        // Drive the h3 connection (control frames, GOAWAY, settings) until
285        // it closes. The SendRequest clones handed out keep the connection
286        // alive; when the last one drops the connection drains and this
287        // task resolves, which `is_live()` observes.
288        let driver = tokio::spawn(async move {
289            let mut h3_conn = h3_conn;
290            let _ = h3_conn.wait_idle().await;
291        });
292        Ok(ConnEntry {
293            send_request,
294            last_used: Instant::now(),
295            driver,
296        })
297    }
298
299    /// Acquire a cloneable request sender for `addr`: reuse a live pooled
300    /// connection if one exists, otherwise dial a new one (bounded by
301    /// `max_conns_per_endpoint`). Dead entries (driver finished) are
302    /// reaped opportunistically here and in [`Self::sweep`].
303    pub async fn acquire(
304        &self,
305        addr: SocketAddr,
306        server_name: &str,
307    ) -> Result<SendRequest<OpenStreams, Bytes>, H3Error> {
308        // Fast path: a live connection is available under the lock. Take
309        // a clone of its SendRequest (cheap; h3 counts clones and keeps
310        // the QUIC connection alive until the last clone drops) and bump
311        // its last-used clock.
312        if let Some(sr) = self.try_take_live(addr) {
313            return Ok(sr);
314        }
315        // No live connection: dial a new one. The dial happens outside
316        // the lock (it is async and may block up to connect_timeout).
317        let entry = self.dial(addr, server_name).await?;
318        let sr = entry.send_request.clone();
319        // Insert, respecting the per-endpoint cap. If the cap is reached
320        // (all slots occupied by live connections raced in), hand back
321        // the fresh sender without pooling it (the connection stays open
322        // via the sender clone; it will be reaped when the caller drops
323        // it) rather than evicting a live connection.
324        let mut conns = self.conns.lock();
325        let bucket = conns.entry(addr).or_default();
326        bucket.retain(|e| e.is_live());
327        if bucket.len() < self.max_conns_per_endpoint {
328            bucket.push(entry);
329        }
330        Ok(sr)
331    }
332
333    /// Try to grab a clone of a live connection's SendRequest for `addr`
334    /// without dialing. Returns None if there is no live pooled
335    /// connection. Stale (driver-finished) entries are reaped as a side
336    /// effect.
337    fn try_take_live(&self, addr: SocketAddr) -> Option<SendRequest<OpenStreams, Bytes>> {
338        let mut conns = self.conns.lock();
339        let bucket = conns.get_mut(&addr)?;
340        // Reap dead entries first so the cap counts only live conns.
341        bucket.retain(|e| e.is_live());
342        // Pick the least-recently-used live connection (spread streams
343        // across connections; approximates round-robin without an index).
344        let now = Instant::now();
345        let picked = bucket.iter_mut().min_by_key(|e| e.last_used)?;
346        picked.last_used = now;
347        Some(picked.send_request.clone())
348    }
349
350    /// Reap connections idle past `idle_timeout` (no stream opened through
351    /// them since `last_used`). Called periodically by the handle's
352    /// sweep task and by tests. Dropping the reaped `ConnEntry` drops its
353    /// `SendRequest` clone; the connection's last sender (in the driver
354    /// task's owned `Connection`) keeps it alive until `wait_idle`
355    /// resolves, so reaping is graceful.
356    pub fn sweep(&self) {
357        let now = Instant::now();
358        let mut conns = self.conns.lock();
359        for bucket in conns.values_mut() {
360            bucket.retain(|e| e.is_live() && now.duration_since(e.last_used) < self.idle_timeout);
361        }
362        conns.retain(|_, v| !v.is_empty());
363    }
364
365    /// Number of live pooled connections for `addr` (observability/tests).
366    pub fn live_count(&self, addr: SocketAddr) -> usize {
367        let mut conns = self.conns.lock();
368        conns
369            .get_mut(&addr)
370            .map(|b| {
371                b.retain(|e| e.is_live());
372                b.len()
373            })
374            .unwrap_or(0)
375    }
376}
377
378/// Send an HTTP/3 request over a QUIC stream (one `send_request` call =
379/// one bidirectional stream) and read the full response. The request body
380/// is sent as a single `DATA` frame (the H3 path buffers the request
381/// body; see the module docs); the response body is collected into one
382/// [`Bytes`] (streaming H3 response bodies are a follow-up).
383///
384/// `send_request` is taken by mutable reference because h3's
385/// `send_request` requires `&mut self`; the caller clones it from the
386/// pool so the pooled connection itself is not borrowed.
387pub async fn h3_request(
388    send_request: &mut SendRequest<OpenStreams, Bytes>,
389    req: http::Request<Bytes>,
390) -> Result<Response<Bytes>, H3Error> {
391    let (parts, body) = req.into_parts();
392    let head = http::Request::from_parts(parts, ());
393    let mut stream = send_request
394        .send_request(head)
395        .await
396        .map_err(H3Error::Stream)?;
397    if !body.is_empty() {
398        stream.send_data(body).await.map_err(H3Error::Stream)?;
399    }
400    stream.finish().await.map_err(H3Error::Stream)?;
401    let resp_head = stream.recv_response().await.map_err(H3Error::Stream)?;
402    let status: StatusCode = resp_head.status();
403    let headers: HeaderMap = resp_head.headers().clone();
404    let mut buf = Vec::new();
405    while let Some(mut chunk) = stream.recv_data().await.map_err(H3Error::Stream)? {
406        // `recv_data` yields `impl Buf`; copy it out. The chunk
407        // is already decrypted QUIC stream data, so a copy is the
408        // cost of buffering (documented v1 limitation).
409        let bytes = chunk.copy_to_bytes(chunk.remaining());
410        buf.extend_from_slice(&bytes);
411    }
412    // Trailers are discarded (the proxy does not forward upstream
413    // trailers today); drain to cleanly close the stream.
414    let _ = stream.recv_trailers().await;
415    let mut resp = Response::new(Bytes::from(buf));
416    *resp.status_mut() = status;
417    *resp.headers_mut() = headers;
418    Ok(resp)
419}
420
421/// Send an HTTP/3 request over a QUIC stream and return a STREAMING
422/// response body (PERF-10, #243). The response headers are returned
423/// immediately; the body is forwarded through a tokio channel as h3
424/// `recv_data` chunks arrive, matching the h1/h2 zero-buffer guarantee.
425///
426/// The driver task reads `recv_data` from the h3 stream and sends each
427/// chunk through the channel. The caller wraps the receiver in an
428/// `UpstreamBody::from_h3_streaming` to get a `hyper::Body`-compatible
429/// streaming body with the idle/deadline/health knobs.
430///
431/// `send_request` is taken by mutable reference because h3's
432/// `send_request` requires `&mut self`; the caller clones it from the
433/// pool so the pooled connection itself is not borrowed.
434pub async fn h3_request_streaming(
435    send_request: &mut SendRequest<OpenStreams, Bytes>,
436    req: http::Request<Bytes>,
437) -> Result<
438    Response<
439        tokio::sync::mpsc::Receiver<Result<Bytes, crate::dataplane::upstream::UpstreamBodyError>>,
440    >,
441    H3Error,
442> {
443    use crate::dataplane::upstream::UpstreamBodyError;
444    let (parts, body) = req.into_parts();
445    let head = http::Request::from_parts(parts, ());
446    let mut stream = send_request
447        .send_request(head)
448        .await
449        .map_err(H3Error::Stream)?;
450    if !body.is_empty() {
451        stream.send_data(body).await.map_err(H3Error::Stream)?;
452    }
453    stream.finish().await.map_err(H3Error::Stream)?;
454    let resp_head = stream.recv_response().await.map_err(H3Error::Stream)?;
455    let status: StatusCode = resp_head.status();
456    let headers: HeaderMap = resp_head.headers().clone();
457
458    // Spawn a driver task that reads recv_data chunks and sends them
459    // through the channel. The channel is bounded to 1 so the driver
460    // applies backpressure: if the consumer is slow, the driver waits
461    // before reading the next chunk (matching the h1/h2 backpressure
462    // model).
463    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, UpstreamBodyError>>(1);
464    tokio::spawn(async move {
465        loop {
466            match stream.recv_data().await {
467                Ok(Some(mut chunk)) => {
468                    let bytes = chunk.copy_to_bytes(chunk.remaining());
469                    if tx.send(Ok(bytes)).await.is_err() {
470                        // Receiver dropped (consumer disconnected); stop
471                        // reading and let the stream close.
472                        break;
473                    }
474                }
475                Ok(None) => {
476                    // End of stream; drain trailers and close.
477                    let _ = stream.recv_trailers().await;
478                    break;
479                }
480                Err(e) => {
481                    // Stream error; forward as an UpstreamBodyError.
482                    let _ = tx
483                        .send(Err(UpstreamBodyError::H3Stream(e.to_string())))
484                        .await;
485                    break;
486                }
487            }
488        }
489    });
490
491    let mut resp = Response::new(rx);
492    *resp.status_mut() = status;
493    *resp.headers_mut() = headers;
494    Ok(resp)
495}
496
497/// Per-upstream H3 transport handle: a [`QuicStreamPool`] plus the
498/// per-upstream timeouts and shared stats. Stored on
499/// [`super::upstream::UpstreamHandle`] for the H3 transport;
500/// the LB, breaker, retry, and health layers are shared with the TCP/TLS
501/// path (they operate on endpoint addresses, not the transport).
502pub struct H3UpstreamHandle {
503    name: String,
504    pool: Arc<QuicStreamPool>,
505    connect_timeout: Duration,
506    read_timeout: Option<Duration>,
507    stats: Arc<UpstreamStats>,
508    sweep: Mutex<Option<JoinHandle<()>>>,
509}
510
511impl H3UpstreamHandle {
512    /// Build an H3 handle for one upstream. `roots` is the trust store
513    /// (webpki default or the upstream's `trusted_ca_file` bundle);
514    /// `connection_cap` bounds QUIC connections per endpoint; the sweep
515    /// task reaps idle connections every `idle_timeout / 2`.
516    pub fn new(
517        name: String,
518        roots: rustls::RootCertStore,
519        connect_timeout: Duration,
520        connection_cap: u32,
521        read_timeout: Option<Duration>,
522        stats: Arc<UpstreamStats>,
523    ) -> Result<Self, H3Error> {
524        let idle_timeout = Duration::from_millis(DEFAULT_H3_IDLE_TIMEOUT_MS);
525        let pool = Arc::new(QuicStreamPool::new(
526            roots,
527            connect_timeout,
528            connection_cap.max(1) as usize,
529            idle_timeout,
530            Arc::clone(&stats),
531        )?);
532        // Background idle sweep: reaps connections not used for
533        // `idle_timeout`. Runs at half the idle window so a connection
534        // is reaped within ~1.5x idle_timeout of its last stream.
535        let sweep_pool = Arc::clone(&pool);
536        let sweep = tokio::spawn(async move {
537            let interval = idle_timeout / 2;
538            loop {
539                tokio::time::sleep(interval).await;
540                sweep_pool.sweep();
541            }
542        });
543        Ok(H3UpstreamHandle {
544            name,
545            pool,
546            connect_timeout,
547            read_timeout,
548            stats,
549            sweep: Mutex::new(Some(sweep)),
550        })
551    }
552
553    /// Upstream name (observability).
554    pub fn name(&self) -> &str {
555        &self.name
556    }
557
558    /// The shared rustls client config (trust roots + h3 ALPN); the QUIC
559    /// active health probe reuses it so a probe and a proxied request
560    /// trust the same roots.
561    pub fn tls_config(&self) -> &Arc<rustls::ClientConfig> {
562        self.pool.tls_config()
563    }
564
565    /// Live pooled QUIC connection count for `addr` (observability/tests).
566    pub fn live_count(&self, addr: SocketAddr) -> usize {
567        self.pool.live_count(addr)
568    }
569
570    /// Send an H3 request to the picked endpoint (`address:port`),
571    /// verifying the upstream's server certificate against `server_name`
572    /// (the endpoint address by default; works for hostname endpoints and
573    /// IP endpoints with IP-SAN certificates). `address` is resolved to a
574    /// `SocketAddr` via `getaddrinfo` on the blocking pool (IP literals
575    /// short-circuit); the first resolved address dials — happy-eyeballs
576    /// racing across QUIC addresses is a follow-up. The per-attempt
577    /// `read_timeout` bounds the whole exchange (resolve + QUIC dial +
578    /// request write + response headers + body), mirroring the TCP/TLS
579    /// path's per-attempt deadline. The shared stats count the request;
580    /// the pool counts new connections.
581    pub async fn send(
582        &self,
583        address: &str,
584        port: u16,
585        server_name: &str,
586        req: http::Request<Bytes>,
587    ) -> Result<Response<Bytes>, UpstreamError> {
588        let _ = self.connect_timeout; // dial timeout lives in the pool
589        self.stats.requests_sent.fetch_add(1, Ordering::Relaxed);
590        let attempt = async {
591            let addr = resolve_one(address, port).await?;
592            let mut sr = self.pool.acquire(addr, server_name).await?;
593            h3_request(&mut sr, req).await
594        };
595        let result = match self.read_timeout {
596            Some(after) => match timeout(after, attempt).await {
597                Ok(r) => r,
598                Err(_elapsed) => return Err(UpstreamError::ReadTimeout { after }),
599            },
600            None => attempt.await,
601        };
602        result.map_err(UpstreamError::from)
603    }
604
605    /// Abort the idle-sweep task (graceful shutdown). The pool itself is
606    /// dropped with the handle; in-flight senders keep their QUIC
607    /// connections alive until they complete.
608    pub fn shutdown(&self) {
609        if let Some(handle) = self.sweep.lock().take() {
610            handle.abort();
611        }
612    }
613}
614
615impl Drop for H3UpstreamHandle {
616    fn drop(&mut self) {
617        self.shutdown();
618    }
619}
620
621impl std::fmt::Debug for H3UpstreamHandle {
622    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
623        f.debug_struct("H3UpstreamHandle")
624            .field("name", &self.name)
625            .field("connect_timeout", &self.connect_timeout)
626            .field("read_timeout", &self.read_timeout)
627            .finish_non_exhaustive()
628    }
629}