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}