dwara_core/dataplane/
upstream.rs

1//! Pooled upstream clients (DW-008, feature analysis 4.1).
2//!
3//! One hyper-util legacy client (a connection pool) per configured
4//! upstream, keyed by upstream name inside an [`UpstreamRegistry`]. Each
5//! pool's connector is tuned per upstream:
6//!
7//! - **Connect timeout**: `timeouts.connect_ms` (default 5 s) wraps the
8//!   whole dial: resolution plus TCP connect plus, for TLS upstreams,
9//!   the TLS handshake.
10//! - **Happy-eyeballs dial** (DW-030, RFC 8305): an endpoint address
11//!   that resolves to multiple addresses — the dual-stack hostname
12//!   case — is dialed with interleaved address-family attempts
13//!   (`timeouts.happy_eyeballs_ms`, default 250 ms, `0` disables):
14//!   the resolver's first address defines the preferred family
15//!   (RFC 8305 leaves the preference to the resolver's order — the
16//!   system's RFC 6724 sort), the first attempt starts immediately,
17//!   each subsequent attempt starts one delay after the previous
18//!   START (or immediately when an attempt fails with nothing else in
19//!   flight), and the first success wins, cancelling the losers.
20//!   Exactly ONE outcome — the overall dial's — reaches the breaker
21//!   and passive-health accounting: the losing arms of one dial are
22//!   dial-internal retries, never endpoint failures. The dial (and
23//!   the [`happy_race`] primitive beneath it) is ours, not
24//!   hyper-util's implicit default: the RFC 8305 shape is now
25//!   documented, configurable, and observable in tests. DNS resolution
26//!   on the pooled dial path runs through the shared async hickory
27//!   `DnsCache` (PERF-05/#170)
28//!   with TTL and negative caching, replacing the blocking-pool
29//!   `getaddrinfo` of `tokio::net::lookup_host`; IP-literal and
30//!   `/etc/hosts` endpoints skip the network. The active health probes
31//!   keep the system resolver (a background, infrequent path).
32//! - **Read timeout (per-attempt)**: `timeouts.read_ms` wraps each pooled
33//!   request/response-header exchange (DW-014) — from the moment the
34//!   request is handed to the pool (including any connection-cap queue
35//!   wait and the dial) until the response HEADERS resolve. It is
36//!   therefore also the per-attempt total bound: pool wait + connect +
37//!   write request + read headers, all inside one `read_ms` deadline.
38//!   A request whose headers do not arrive in time fails with
39//!   [`UpstreamError::ReadTimeout`] (classified 504 by the proxy, and a
40//!   retryable transport-class failure when retries are enabled). The
41//!   response BODY is not covered by `read_ms`.
42//! - **Write timeout (body idle)**: `timeouts.write_ms` bounds the
43//!   response body stream as an INACTIVITY timeout (DW-014): the body
44//!   wrapper [`UpstreamBody`] errors with [`UpstreamBodyError::WriteTimeout`]
45//!   when the gap between two body frames exceeds `write_ms`. It is not a
46//!   total streaming budget — a body that keeps trickling frames never
47//!   trips it. The wrapper also reports a mid-stream abort (transport
48//!   error or idle timeout after headers resolved) as a passive-health
49//!   FAILURE for the picked endpoint, closing the DW-012 gap where
50//!   mid-body deaths were invisible to ejection. Requires a body wrapper:
51//!   the handle's response type is `Response<UpstreamBody>`, not
52//!   `Response<Incoming>` (documented; the wrapper is a thin frame
53//!   passthrough when both knobs are unset).
54//! - **Connection cap**: `connection_cap` (default 256) bounds concurrent
55//!   outbound connections to the upstream — active AND pooled-idle — via a
56//!   semaphore permit acquired before dialing and stored inside the
57//!   connection's IO wrapper, so it is released exactly when the
58//!   connection closes (permit drop). Excess connection attempts queue on
59//!   the semaphore; they are never failed, only delayed. Because the
60//!   permit wait has no deadline of its own, requests can queue
61//!   indefinitely while the cap is saturated; request-level timeouts
62//!   (DW-014) will bound that wait. The effective cap is clamped to a
63//!   minimum of 1: validated configs always pass a cap >= 1, but direct
64//!   construction with cap == 0 would build a zero-permit semaphore whose
65//!   dials hang silently — misuse degrades to serial connects instead.
66//!   Documented choice:
67//!   the cap counts connections, not in-flight requests — HTTP/1.1
68//!   multiplexes several requests over one connection only sequentially,
69//!   and h2 pools share one connection per origin anyway.
70//! - **Pending cap** (DW-015): `max_pending` (default 256) bounds how many
71//!   requests may WAIT for a connection-cap slot. Over that, the connector
72//!   fails fast with [`UpstreamError::Saturated`] (classified 503 "upstream
73//!   saturated" by the proxy) instead of queueing; a pending slot is held
74//!   from the dial attempt until the connection-cap permit is acquired,
75//!   then released (the request is connecting, no longer pending). The
76//!   default bounds memory under high concurrency (#270); operators who
77//!   need deeper queueing can set `max_pending` explicitly.
78//! - **TLS**: `https` upstreams negotiate TLS with ALPN `http/1.1`;
79//!   `http2` upstreams negotiate TLS with ALPN `h2` and lock the client to
80//!   HTTP/2; `http1` upstreams dial plaintext. Server certificates are
81//!   verified against the Mozilla webpki root set by default (chosen over
82//!   system roots for determinism in tests; system roots are a follow-up).
83//!   Private-CA upstreams are configured per upstream via
84//!   `trusted_ca_file` (#121): the PEM bundle REPLACES the public roots
85//!   for that upstream, and its active https health probes verify against
86//!   the same roots (see the handle's [`UpstreamHandle::tls_roots`]).
87//!   Programmatic callers can still add extra roots on top of the public
88//!   set via [`UpstreamRegistry::with_root_certificates`].
89//!
90//! Load balancing (DW-011): every dispatch picks its endpoint through the
91//! upstream's [`crate::dataplane::balance::UpstreamLb`] (smooth weighted round-robin,
92//! least-connections, random-2, or ketama ip-hash; slow-start ramps; hot
93//! endpoint-set swaps that carry per-address state). Config lifecycle:
94//! build a registry from a published snapshot; reloads rebuild it from the
95//! new snapshot while carrying balancer state (see
96//! [`UpstreamRegistry::from_snapshot_with_previous`]), mirroring how
97//! `TlsTermination` is reloaded.
98//!
99//! Retries (DW-014) are driven from the proxy (which owns the request
100//! body replay decision); the handle exposes the resolved
101//! [`crate::resilience::retries::RetryParams`] and the per-upstream
102//! [`crate::resilience::retries::RetryBudget`] for that loop, and every `send` call is
103//! one ATTEMPT (fresh load-balancer pick, fresh per-attempt deadlines).
104
105use std::collections::{BTreeMap, VecDeque};
106use std::future::Future;
107use std::net::SocketAddr;
108use std::pin::Pin;
109use std::sync::atomic::{AtomicU64, Ordering};
110use std::sync::Arc;
111use std::task::{Context, Poll};
112use std::time::Duration;
113
114use http::uri::{Authority, PathAndQuery, Scheme};
115use http_body_util::BodyExt as _;
116use hyper::body::{Bytes, Frame, Incoming};
117use hyper::{Request, Response, Uri, Version};
118use hyper_util::client::legacy::connect::{Connected, Connection as HyperConnection};
119use hyper_util::client::legacy::Client;
120use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
121use rustls::pki_types::{CertificateDer, ServerName};
122use std::str::FromStr as _;
123use tokio::net::TcpStream;
124use tokio::sync::{OwnedSemaphorePermit, Semaphore};
125use tokio::task::JoinSet;
126use tower_service::Service;
127
128/// The hyper-util client type used by [`UpstreamHandle`], aliased to keep
129/// the sharded `clients: Vec<UpstreamClient>` field readable.
130type UpstreamClient = Client<
131    UpstreamConnector,
132    http_body_util::combinators::UnsyncBoxBody<
133        bytes::Bytes,
134        Box<dyn std::error::Error + Send + Sync>,
135    >,
136>;
137
138use crate::config::limits::MAX_SLOW_START_MS;
139use crate::config::{Timeouts, Upstream, UpstreamProtocol};
140use crate::dataplane::discovery::DnsCache;
141use crate::observability::Observability;
142use crate::resilience::breaker::{Breaker, BreakerParams, BreakerState};
143use crate::resilience::health::HealthDispatch;
144use crate::resilience::retries::{HedgeParams, RetryBudget, RetryParams};
145use crate::snapshot::Snapshot;
146
147/// Default connection cap when `connection_cap` is absent.
148///
149/// Raised from 64 to 256 (#270): the previous default of 64 caused
150/// unbounded memory growth under high concurrency — excess requests
151/// queued on the semaphore with their full request context held in
152/// memory, and the allocator retained those pages. 256 is a reasonable
153/// middle ground for modern workloads; operators with higher
154/// concurrency needs should set `connection_cap` explicitly.
155pub const DEFAULT_CONNECTION_CAP: u32 = 256;
156/// Default pending-request cap when `max_pending` is absent (#270).
157///
158/// Previously absent = unbounded queueing, which allowed memory to
159/// grow without limit when the connection cap was saturated. A
160/// default of 256 bounds the queue so excess requests get 503
161/// "upstream saturated" (backpressure) instead of queueing
162/// indefinitely. This aligns with the AGENTS.md rule that buffering
163/// must be size-capped. Operators who need deeper queueing can set
164/// `max_pending` explicitly.
165pub const DEFAULT_MAX_PENDING: u32 = 256;
166/// Default connect timeout when `timeouts.connect_ms` is absent.
167pub const DEFAULT_CONNECT_TIMEOUT_MS: u64 = 5_000;
168/// Maximum pool shard count (caps memory overhead of per-shard pools).
169const MAX_POOL_SHARDS: usize = 16;
170
171/// Number of hyper-util client pool shards per upstream. Each shard is an
172/// independent `Client` with its own `Mutex<PoolInner>`, so the pool lock
173/// contention that dominates the hot path under high concurrency is
174/// distributed N-fold. The connector (and its connection-cap semaphore) is
175/// shared across shards via `Arc`, so the TOTAL connection cap still
176/// applies — the cap is NOT multiplied by the shard count.
177///
178/// `DWARA_POOL_SHARDS` env var overrides; default is 1 (the previous
179/// single-pool behavior, preserving connection-reuse semantics). Set to
180/// `available_parallelism()` (or the number of tokio workers) to reduce
181/// pool-lock contention under high concurrency. The tradeoff: with N
182/// shards, N connections to each upstream endpoint may be open (one per
183/// shard) instead of 1, slightly increasing upstream connection count
184/// (still bounded by `connection_cap`).
185fn pool_shard_count() -> usize {
186    let default = 1;
187    std::env::var("DWARA_POOL_SHARDS")
188        .ok()
189        .and_then(|v| v.parse::<usize>().ok())
190        .map(|n| n.clamp(1, MAX_POOL_SHARDS))
191        .unwrap_or(default)
192}
193
194/// System millisecond clock for a freshly built breaker (the breaker's
195/// own `system_now_ms` is private to its module).
196fn system_now_ms_for_breaker() -> u64 {
197    std::time::SystemTime::now()
198        .duration_since(std::time::UNIX_EPOCH)
199        .map(|d| d.as_millis() as u64)
200        .unwrap_or(0)
201}
202
203/// Error sending a request to an upstream.
204#[derive(Debug)]
205pub enum UpstreamError {
206    /// `send` was called on an upstream with no endpoints. Only reachable
207    /// via unvalidated direct construction; config validation rejects
208    /// empty endpoint lists.
209    NoEndpoints,
210    /// A root certificate supplied to
211    /// [`UpstreamRegistry::with_root_certificates`] could not be parsed
212    /// or was otherwise unusable as a trust anchor.
213    InvalidRootCertificate(String),
214    /// The endpoint address is not usable as a TLS server name.
215    InvalidHost(String),
216    /// Dialing (TCP or TLS handshake) exceeded the connect timeout.
217    ConnectTimeout { after: Duration },
218    /// The per-attempt deadline (`timeouts.read_ms`) expired before the
219    /// response headers resolved. Covers pool-queue wait + connect +
220    /// request write + header read; see the module docs (DW-014).
221    ReadTimeout { after: Duration },
222    /// The per-upstream pending cap (`max_pending`, DW-015) is full: the
223    /// request would have to WAIT for an outbound connection slot and the
224    /// config chose immediate rejection over queueing. Classified 503
225    /// ("upstream saturated") by the proxy; NOT retryable (the upstream is
226    /// saturated by definition — a retry adds load).
227    Saturated,
228    /// Transport-level I/O failure while connecting.
229    Io(std::io::Error),
230    /// The hyper client failed to complete the request (broken pool
231    /// connection, framing error, ...).
232    Client(hyper_util::client::legacy::Error),
233    /// DW-108: the upstream is configured `protocol: h3` but the H3
234    /// transport is unavailable. When the `h3` cargo feature is off the
235    /// H3 connector is not compiled, so an `h3` upstream is inert and
236    /// every dispatch fails closed with this error rather than silently
237    /// falling back to a wrong transport. Classified 502 by the proxy;
238    /// not health-reportable (a configuration/feature gap, not an
239    /// endpoint failure).
240    H3Unavailable,
241}
242
243impl std::fmt::Display for UpstreamError {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            UpstreamError::NoEndpoints => write!(f, "upstream has no endpoints"),
247            UpstreamError::Saturated => {
248                write!(f, "upstream pending queue is saturated")
249            }
250            UpstreamError::InvalidRootCertificate(e) => {
251                write!(f, "unusable root certificate: {e}")
252            }
253            UpstreamError::InvalidHost(h) => {
254                write!(f, "endpoint address '{h}' is not a valid TLS server name")
255            }
256            UpstreamError::ConnectTimeout { after } => {
257                write!(f, "upstream connect timed out after {after:?}")
258            }
259            UpstreamError::ReadTimeout { after } => {
260                write!(f, "upstream response headers timed out after {after:?}")
261            }
262            UpstreamError::Io(e) => write!(f, "upstream connect failed: {e}"),
263            UpstreamError::Client(e) => write!(f, "upstream request failed: {e}"),
264            UpstreamError::H3Unavailable => write!(
265                f,
266                "upstream protocol h3 requires the `h3` cargo feature (build with --features h3)"
267            ),
268        }
269    }
270}
271
272impl std::error::Error for UpstreamError {}
273
274/// Whether a send-path error reflects a genuine transport/exchange
275/// failure of the picked endpoint (and therefore feeds passive health).
276/// Client-side admission rejections (Saturated — the request never
277/// contacted the endpoint) and configuration-class errors (NoEndpoints,
278/// InvalidRootCertificate, InvalidHost) would eject the endpoint for
279/// reasons unrelated to its health, so they are not reported.
280fn health_reportable(err: &UpstreamError) -> bool {
281    !matches!(
282        err,
283        UpstreamError::Saturated
284            | UpstreamError::NoEndpoints
285            | UpstreamError::InvalidRootCertificate(_)
286            | UpstreamError::InvalidHost(_)
287            | UpstreamError::H3Unavailable
288    )
289}
290
291/// [`health_reportable`] over a legacy client error: our typed connector
292/// errors (Saturated, InvalidHost, ...) ride in its source chain, so walk
293/// it; anything else is a genuine transport failure of the exchange.
294fn health_reportable_legacy(err: &hyper_util::client::legacy::Error) -> bool {
295    let mut src: Option<&(dyn std::error::Error + 'static)> = Some(err);
296    while let Some(s) = src {
297        if let Some(u) = s.downcast_ref::<UpstreamError>() {
298            return health_reportable(u);
299        }
300        src = s.source();
301    }
302    true
303}
304
305impl From<std::io::Error> for UpstreamError {
306    fn from(e: std::io::Error) -> Self {
307        UpstreamError::Io(e)
308    }
309}
310
311impl From<hyper_util::client::legacy::Error> for UpstreamError {
312    fn from(e: hyper_util::client::legacy::Error) -> Self {
313        // The legacy client wraps connector errors (ours included) in its
314        // Connect variant; lift our typed errors back out so callers can
315        // match e.g. ConnectTimeout without digging through sources.
316        let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
317        while let Some(s) = src {
318            if let Some(u) = s.downcast_ref::<UpstreamError>() {
319                match u {
320                    UpstreamError::ConnectTimeout { after } => {
321                        return UpstreamError::ConnectTimeout { after: *after }
322                    }
323                    UpstreamError::InvalidHost(h) => return UpstreamError::InvalidHost(h.clone()),
324                    UpstreamError::Saturated => return UpstreamError::Saturated,
325                    _ => {}
326                }
327            }
328            src = std::error::Error::source(s);
329        }
330        UpstreamError::Client(e)
331    }
332}
333
334/// Error surfaced by a streamed upstream response body
335/// ([`UpstreamBody`], DW-014).
336#[derive(Debug)]
337pub enum UpstreamBodyError {
338    /// The underlying transport errored mid-stream (connection reset,
339    /// framing error, ...).
340    Upstream(hyper::Error),
341    /// PERF-10 (#243): an H3/QUIC stream errored mid-body (h3 frame
342    /// error, QPACK error, QUIC stream reset, ...). The h3 error is
343    /// stringified because `h3::error::StreamError` is not `Clone` and
344    /// the channel carries `Result<Bytes, UpstreamBodyError>` across
345    /// an `await` boundary in the driver task.
346    H3Stream(String),
347    /// The gap between two body frames exceeded `timeouts.write_ms`
348    /// (inactivity timeout; see the module docs).
349    WriteTimeout { after: Duration },
350    /// The response body crossed its ABSOLUTE deadline (DW-039: a
351    /// gRPC request's `grpc-timeout` covers the whole RPC, so the
352    /// deadline armed at dispatch keeps ticking through the body).
353    DeadlineExceeded,
354}
355
356impl std::fmt::Display for UpstreamBodyError {
357    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        match self {
359            UpstreamBodyError::Upstream(e) => write!(f, "upstream body failed: {e}"),
360            UpstreamBodyError::H3Stream(msg) => {
361                write!(f, "h3 upstream stream failed: {msg}")
362            }
363            UpstreamBodyError::WriteTimeout { after } => {
364                write!(f, "upstream body stalled for more than {after:?}")
365            }
366            UpstreamBodyError::DeadlineExceeded => {
367                write!(f, "response crossed its request deadline (grpc-timeout)")
368            }
369        }
370    }
371}
372
373impl std::error::Error for UpstreamBodyError {}
374
375/// Streaming upstream response body: the pooled `Incoming` wrapped with the
376/// DW-014 response-side knobs. A thin frame passthrough when both knobs
377/// are unset; otherwise:
378///
379/// - `idle` (from `timeouts.write_ms`): an inactivity timeout — if the gap
380///   between two consecutive body frames exceeds the duration, the body
381///   errors with [`UpstreamBodyError::WriteTimeout`]. Implemented by
382///   arming a `tokio::time::Sleep` on every `Pending` poll and clearing it
383///   on every frame; the timer is only alive while the stream is idle, so
384///   an actively-trickling body never trips it (documented: it bounds
385///   stalls, not total streaming time).
386/// - `health`: when the dispatch carried passive health, a mid-stream
387///   failure (transport error or idle timeout — either way the endpoint
388///   died AFTER headers resolved) is reported as a health FAILURE to the
389///   picked endpoint's tracker, closing the DW-012 gap where mid-body
390///   aborts were invisible to ejection. A clean end-of-stream reports
391///   nothing: the header-resolution report already classified the
392///   exchange, and doubling successes would dilute failure ratios.
393///
394/// A channel-backed streaming body for H3 responses (PERF-10, #243).
395/// Wraps a tokio mpsc receiver that receives `Result<Bytes,
396/// UpstreamBodyError>` chunks from a driver task that reads h3
397/// `recv_data`. Implements `hyper::body::Body` by polling the
398/// receiver; the bounded channel (capacity 1) applies backpressure
399/// from the consumer to the upstream.
400struct H3ChannelBody {
401    rx: tokio::sync::mpsc::Receiver<Result<Bytes, UpstreamBodyError>>,
402}
403
404impl hyper::body::Body for H3ChannelBody {
405    type Data = Bytes;
406    type Error = UpstreamBodyError;
407
408    fn poll_frame(
409        self: Pin<&mut Self>,
410        cx: &mut Context<'_>,
411    ) -> Poll<Option<Result<Frame<Bytes>, UpstreamBodyError>>> {
412        let this = self.get_mut();
413        match this.rx.poll_recv(cx) {
414            Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(Ok(Frame::data(bytes)))),
415            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
416            Poll::Ready(None) => Poll::Ready(None),
417            Poll::Pending => Poll::Pending,
418        }
419    }
420}
421
422/// The error is terminal for the stream: frames already forwarded to the
423/// client end abruptly (HTTP/1.1 truncation semantics); it is never
424/// retried (an attempt is final once its headers resolved).
425pub struct UpstreamBody {
426    /// The wrapped upstream response body. A boxed body so the H3
427    /// transport (DW-108) can supply a buffered body alongside the
428    /// TCP/TLS path's streaming `Incoming`: the legacy path boxes an
429    /// `Incoming` (mapping `hyper::Error` to [`UpstreamBodyError`]), the
430    /// H3 path boxes a `Full<Bytes>` (the response is buffered; see
431    /// `dataplane::upstream_h3`'s module docs). Both share the
432    /// `Bytes`/`UpstreamBodyError` body shape, so the idle/deadline/
433    /// health-reporting wrapper below is transport-agnostic.
434    inner: http_body_util::combinators::BoxBody<Bytes, UpstreamBodyError>,
435    idle: Option<Duration>,
436    sleep: Option<Pin<Box<tokio::time::Sleep>>>,
437    health: Option<(Arc<crate::dataplane::balance::UpstreamLb>, HealthDispatch)>,
438    /// Gateway concurrency-cap permit (DW-015), attached by the proxy to
439    /// STREAMING responses so the global slot is held until the body
440    /// completes (or the response is dropped — client disconnect included).
441    /// Dropped with the body; no polling logic needed.
442    release: Option<OwnedSemaphorePermit>,
443    /// ABSOLUTE deadline for the body (DW-039): a gRPC request's
444    /// `grpc-timeout` covers the whole RPC, so the same deadline that
445    /// bounded the forward keeps ticking here. Checked at the top of
446    /// every poll; unlike `idle`, no timer is armed — the deadline is
447    /// compared against the clock each poll the frame path runs, and
448    /// the body's own activity drives polling (a body mid-flight
449    /// polls constantly; a stalled one is bounded by the idle timer
450    /// first). `None` = no deadline.
451    deadline: Option<std::time::Instant>,
452    /// REL-06 (#217): optional in-flight guard held until the body
453    /// completes (or is dropped). When present, the in-flight counter
454    /// is decremented when the body stream ends (Poll::Ready(None)) or
455    /// when the body is dropped — NOT when headers resolve. This gives
456    /// `least_requests`, `random`, and `peak_ewma` a more accurate
457    /// view of actual endpoint load. Default behavior (when `None`)
458    /// remains header-resolution release (v1).
459    inflight_guard: Option<crate::dataplane::balance::InflightGuard>,
460}
461
462impl hyper::body::Body for UpstreamBody {
463    type Data = Bytes;
464    type Error = UpstreamBodyError;
465
466    fn poll_frame(
467        self: Pin<&mut Self>,
468        cx: &mut Context<'_>,
469    ) -> Poll<Option<Result<Frame<Bytes>, UpstreamBodyError>>> {
470        let this = self.get_mut();
471        loop {
472            // Absolute deadline (DW-039): fires whether or not the inner
473            // stream is active — the RPC is over when it is over.
474            if let Some(deadline) = this.deadline {
475                if std::time::Instant::now() >= deadline {
476                    this.report_health_failure();
477                    return Poll::Ready(Some(Err(UpstreamBodyError::DeadlineExceeded)));
478                }
479            }
480            // Idle deadline armed: check it before (and after) polling the
481            // inner stream so an elapsed stall errors even when the inner
482            // stream is still Pending.
483            if let Some(sleep) = &mut this.sleep {
484                if sleep.as_mut().poll(cx).is_ready() {
485                    this.report_health_failure();
486                    return Poll::Ready(Some(Err(UpstreamBodyError::WriteTimeout {
487                        after: this.idle.unwrap_or_default(),
488                    })));
489                }
490            }
491            match Pin::new(&mut this.inner).poll_frame(cx) {
492                Poll::Ready(Some(Ok(frame))) => {
493                    // Activity: clear any armed deadline.
494                    this.sleep = None;
495                    return Poll::Ready(Some(Ok(frame)));
496                }
497                Poll::Ready(Some(Err(e))) => {
498                    this.report_health_failure();
499                    return Poll::Ready(Some(Err(e)));
500                }
501                Poll::Ready(None) => return Poll::Ready(None),
502                Poll::Pending => {
503                    if this.idle.is_some() && this.sleep.is_none() {
504                        this.sleep =
505                            Some(Box::pin(tokio::time::sleep(this.idle.unwrap_or_default())));
506                        // Loop once more so the fresh timer registers its
507                        // waker with the executor before we return Pending.
508                        continue;
509                    }
510                    return Poll::Pending;
511                }
512            }
513        }
514    }
515
516    fn size_hint(&self) -> hyper::body::SizeHint {
517        self.inner.size_hint()
518    }
519
520    fn is_end_stream(&self) -> bool {
521        self.inner.is_end_stream()
522    }
523}
524
525impl std::fmt::Debug for UpstreamBody {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        f.debug_struct("UpstreamBody")
528            .field("idle_timeout", &self.idle)
529            .field("health_reporting", &self.health.is_some())
530            .finish_non_exhaustive()
531    }
532}
533
534impl UpstreamBody {
535    /// Wrap a streaming TCP/TLS upstream body (`Incoming`) with the
536    /// DW-014 idle/deadline/health knobs. `idle` is the response-body
537    /// inactivity timeout (`timeouts.write_ms`); `health` carries the
538    /// dispatch's passive-health tracker for mid-body failure reporting.
539    /// The legacy pooled-client path uses this constructor.
540    pub fn from_incoming(
541        inner: Incoming,
542        idle: Option<Duration>,
543        health: Option<(Arc<crate::dataplane::balance::UpstreamLb>, HealthDispatch)>,
544    ) -> Self {
545        UpstreamBody {
546            inner: inner.map_err(UpstreamBodyError::Upstream).boxed(),
547            idle,
548            sleep: None,
549            health,
550            release: None,
551            deadline: None,
552            inflight_guard: None,
553        }
554    }
555
556    /// Wrap a fully-buffered H3 response body (DW-108). The H3 transport
557    /// collects the response into one `Bytes` (streaming H3 bodies are a
558    /// follow-up; see `dataplane::upstream_h3`); the wrapper still
559    /// applies the deadline knob (the body is already complete, so the
560    /// idle timer never arms). `health` is `None` for H3 today (a buffered
561    /// body cannot mid-stream abort, so there is no mid-body failure to
562    /// report).
563    pub fn from_buffered(body: Bytes) -> Self {
564        UpstreamBody {
565            inner: http_body_util::Full::new(body)
566                .map_err(|e: std::convert::Infallible| match e {})
567                .boxed(),
568            idle: None,
569            sleep: None,
570            health: None,
571            release: None,
572            deadline: None,
573            inflight_guard: None,
574        }
575    }
576
577    /// Wrap a streaming H3 response body (PERF-10, #243): a
578    /// channel-backed body that forwards h3 `recv_data` chunks as they
579    /// arrive, matching the h1/h2 zero-buffer guarantee. The H3
580    /// transport spawns a driver task that reads `recv_data` from the
581    /// h3 stream and sends each chunk through the channel; this
582    /// constructor wraps the receiver as a `BoxBody` so the
583    /// idle/deadline/health knobs apply uniformly across transports.
584    pub fn from_h3_streaming(
585        rx: tokio::sync::mpsc::Receiver<Result<Bytes, UpstreamBodyError>>,
586        idle: Option<Duration>,
587        health: Option<(Arc<crate::dataplane::balance::UpstreamLb>, HealthDispatch)>,
588    ) -> Self {
589        let body = H3ChannelBody { rx };
590        UpstreamBody {
591            inner: body.boxed(),
592            idle,
593            sleep: None,
594            health,
595            release: None,
596            deadline: None,
597            inflight_guard: None,
598        }
599    }
600
601    /// Report a mid-stream failure to the dispatch's health tracker, if any.
602    fn report_health_failure(&mut self) {
603        if let Some((lb, hd)) = &self.health.take() {
604            hd.report(lb.now_ms(), true);
605        }
606    }
607
608    /// Attach the gateway concurrency-cap permit (DW-015): it releases
609    /// when this body completes or is dropped, which is exactly the
610    /// "release at body completion" contract of the global cap.
611    pub fn set_release_permit(&mut self, permit: OwnedSemaphorePermit) {
612        self.release = Some(permit);
613    }
614
615    /// Arm the ABSOLUTE body deadline (DW-039): set by the proxy from a
616    /// gRPC request's `grpc-timeout` (the deadline that bounded the
617    /// forward, continuing through the body — the RPC's total budget).
618    pub fn set_deadline(&mut self, deadline: std::time::Instant) {
619        self.deadline = Some(deadline);
620    }
621
622    /// REL-06 (#217): attach the in-flight guard so the endpoint's
623    /// in-flight counter is held until the body stream completes (or
624    /// the body is dropped). Used when `body_completion_inflight` is
625    /// enabled in the upstream's health config.
626    pub fn set_inflight_guard(&mut self, guard: crate::dataplane::balance::InflightGuard) {
627        self.inflight_guard = Some(guard);
628    }
629
630    /// REL-05 (#216): create an empty body (used as a placeholder while
631    /// the first-frame buffer drains the real body).
632    pub fn empty() -> Self {
633        UpstreamBody {
634            inner: http_body_util::Empty::new()
635                .map_err(|e: std::convert::Infallible| match e {})
636                .boxed(),
637            idle: None,
638            sleep: None,
639            health: None,
640            release: None,
641            deadline: None,
642            inflight_guard: None,
643        }
644    }
645
646    /// REL-05 (#216): replace the inner body with a pinned boxed body
647    /// (the remaining stream after the first-frame buffer drained the
648    /// prefix). The idle/deadline/health knobs are preserved.
649    pub fn set_inner(
650        &mut self,
651        inner: http_body_util::combinators::BoxBody<Bytes, UpstreamBodyError>,
652    ) {
653        self.inner = inner;
654    }
655
656    /// REL-05 (#216): replace the inner body with a fully-buffered
657    /// `Bytes` (the body completed within the first-frame buffer).
658    pub fn set_full(&mut self, body: Bytes) {
659        self.inner = http_body_util::Full::new(body)
660            .map_err(|e: std::convert::Infallible| match e {})
661            .boxed();
662    }
663
664    /// REL-05 (#216): create a body that emits a `prefix` then continues
665    /// with `rest`. Used when the first-frame buffer filled: the prefix
666    /// is flushed to the client, then the remaining upstream body
667    /// streams frame-by-frame.
668    pub fn with_prefix(prefix: Bytes, mut rest: UpstreamBody) -> Self {
669        // We extract the inner body from `rest` and build a chained
670        // body that emits the prefix first, then the rest's inner.
671        // The idle/deadline/health knobs are preserved on the new body.
672        let idle = rest.idle;
673        let health = rest.health.take();
674        let release = rest.release.take();
675        let deadline = rest.deadline;
676        let inflight_guard = rest.inflight_guard.take();
677        let rest_inner = std::mem::replace(
678            &mut rest.inner,
679            http_body_util::Empty::new()
680                .map_err(|e: std::convert::Infallible| match e {})
681                .boxed(),
682        );
683        let chained = http_body_util::combinators::BoxBody::new(PrefixBody {
684            prefix: Some(prefix),
685            rest: rest_inner,
686        });
687        UpstreamBody {
688            inner: chained,
689            idle,
690            sleep: None,
691            health,
692            release,
693            deadline,
694            inflight_guard,
695        }
696    }
697}
698
699/// REL-05 (#216): a body that emits a prefix `Bytes` frame first, then
700/// delegates to the rest of the upstream body. Used by
701/// [`UpstreamBody::with_prefix`] when the first-frame buffer filled.
702struct PrefixBody {
703    prefix: Option<Bytes>,
704    rest: http_body_util::combinators::BoxBody<Bytes, UpstreamBodyError>,
705}
706
707impl hyper::body::Body for PrefixBody {
708    type Data = Bytes;
709    type Error = UpstreamBodyError;
710
711    fn poll_frame(
712        mut self: Pin<&mut Self>,
713        cx: &mut Context<'_>,
714    ) -> Poll<Option<Result<Frame<Bytes>, UpstreamBodyError>>> {
715        if let Some(prefix) = self.prefix.take() {
716            return Poll::Ready(Some(Ok(Frame::data(prefix))));
717        }
718        Pin::new(&mut self.rest).poll_frame(cx)
719    }
720}
721
722/// Live connection counters for one upstream; observability for pooling
723/// behavior (used by tests and later by the admin surface).
724#[derive(Debug, Default)]
725pub struct UpstreamStats {
726    /// Total TCP/TLS connections established to this upstream since the
727    /// handle was built. Stays flat when the pool reuses connections.
728    pub connections_opened: AtomicU64,
729    /// Total requests handed to the pool.
730    pub requests_sent: AtomicU64,
731}
732
733/// The transport under a pooled connection: plaintext TCP or TLS, wrapped
734/// in `TokioIo` to bridge tokio traits to hyper's runtime traits.
735enum Transport {
736    Plain(TokioIo<TcpStream>),
737    Tls(Box<TokioIo<tokio_rustls::client::TlsStream<TcpStream>>>),
738}
739
740impl hyper::rt::Read for Transport {
741    fn poll_read(
742        mut self: Pin<&mut Self>,
743        cx: &mut Context<'_>,
744        buf: hyper::rt::ReadBufCursor<'_>,
745    ) -> Poll<std::io::Result<()>> {
746        match &mut *self {
747            Transport::Plain(s) => Pin::new(s).poll_read(cx, buf),
748            Transport::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
749        }
750    }
751}
752
753impl hyper::rt::Write for Transport {
754    fn poll_write(
755        mut self: Pin<&mut Self>,
756        cx: &mut Context<'_>,
757        buf: &[u8],
758    ) -> Poll<std::io::Result<usize>> {
759        match &mut *self {
760            Transport::Plain(s) => Pin::new(s).poll_write(cx, buf),
761            Transport::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
762        }
763    }
764
765    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
766        match &mut *self {
767            Transport::Plain(s) => Pin::new(s).poll_flush(cx),
768            Transport::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
769        }
770    }
771
772    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
773        match &mut *self {
774            Transport::Plain(s) => Pin::new(s).poll_shutdown(cx),
775            Transport::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
776        }
777    }
778}
779
780/// Pooled connection IO: the transport plus the connection-cap permit.
781/// The permit lives exactly as long as the connection: when the pool drops
782/// a closed/evicted connection, the permit is released and the next queued
783/// dial may proceed.
784struct CappedStream {
785    transport: Transport,
786    _permit: OwnedSemaphorePermit,
787}
788
789impl hyper::rt::Read for CappedStream {
790    fn poll_read(
791        mut self: Pin<&mut Self>,
792        cx: &mut Context<'_>,
793        buf: hyper::rt::ReadBufCursor<'_>,
794    ) -> Poll<std::io::Result<()>> {
795        Pin::new(&mut self.transport).poll_read(cx, buf)
796    }
797}
798
799impl hyper::rt::Write for CappedStream {
800    fn poll_write(
801        mut self: Pin<&mut Self>,
802        cx: &mut Context<'_>,
803        buf: &[u8],
804    ) -> Poll<std::io::Result<usize>> {
805        Pin::new(&mut self.transport).poll_write(cx, buf)
806    }
807
808    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
809        Pin::new(&mut self.transport).poll_flush(cx)
810    }
811
812    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
813        Pin::new(&mut self.transport).poll_shutdown(cx)
814    }
815}
816
817impl HyperConnection for CappedStream {
818    fn connected(&self) -> Connected {
819        Connected::new()
820    }
821}
822
823/// Default happy-eyeballs inter-connection delay when
824/// `timeouts.happy_eyeballs_ms` is absent (the RFC 8305 recommended
825/// value; DW-030).
826pub const DEFAULT_HAPPY_EYEBALLS_MS: u64 = 250;
827
828/// `timeouts.happy_eyeballs_ms`, resolved to the effective racing delay:
829/// absent -> the RFC 8305 default; `0` -> None (racing disabled, strict
830/// resolver order); otherwise the configured value (clamped to the
831/// ceiling in `config::limits`; validation has already rejected
832/// over-cap values).
833fn effective_happy_eyeballs(u: &Upstream) -> Option<Duration> {
834    match u.timeouts.as_ref().and_then(|t| t.happy_eyeballs_ms) {
835        None => Some(Duration::from_millis(DEFAULT_HAPPY_EYEBALLS_MS)),
836        Some(0) => None,
837        Some(ms) => Some(Duration::from_millis(
838            ms.min(crate::config::limits::MAX_HAPPY_EYEBALLS_MS),
839        )),
840    }
841}
842
843/// RFC 8305 address ordering (DW-030): the resolver's FIRST address keeps
844/// its place and defines the preferred family; the remainder follow with
845/// the OTHER family second, alternating onward — so the gateway reaches
846/// the second family after one inter-connection delay no matter how long
847/// the preferred family's list is.
848///
849/// `#[doc(hidden)]` public for the unit tests in `tests/unit/upstream.rs`
850/// (pure ordering function; exercising it through real dials would need
851/// controllable DNS, which the test environment cannot provide).
852#[doc(hidden)]
853pub fn interleave_order(addrs: &[SocketAddr]) -> Vec<SocketAddr> {
854    if addrs.len() < 2 {
855        return addrs.to_vec();
856    }
857    let preferred_v6 = addrs[0].is_ipv6();
858    let mut preferred: VecDeque<SocketAddr> = VecDeque::new();
859    let mut other: VecDeque<SocketAddr> = VecDeque::new();
860    for a in addrs {
861        if a.is_ipv6() == preferred_v6 {
862            preferred.push_back(*a);
863        } else {
864            other.push_back(*a);
865        }
866    }
867    let mut out = Vec::with_capacity(addrs.len());
868    while let (Some(a), maybe_b) = (preferred.pop_front(), other.front()) {
869        out.push(a);
870        if let Some(b) = maybe_b {
871            out.push(*b);
872            other.pop_front();
873        }
874    }
875    out
876}
877
878/// Race connection attempts across addresses, RFC 8305 shape (DW-030).
879///
880/// Starts an attempt on `seq`'s first address immediately; each further
881/// attempt starts `delay` after the previous START — or immediately when
882/// an attempt FAILS and nothing else is in flight (RFC 8305 5.2's
883/// failure fast-forward). The first success wins and cancelling the
884/// losers is dropping the [`JoinSet`]; an all-failed race surfaces the
885/// LAST error. Generic over the dial so the unit tests can drive it with
886/// controllable futures (real dials cannot make "the first address hangs"
887/// deterministic on loopback); `#[doc(hidden)]` for that test seam.
888#[doc(hidden)]
889pub async fn happy_race<F, Fut, T>(
890    seq: Vec<SocketAddr>,
891    delay: Option<Duration>,
892    dial: F,
893) -> std::io::Result<T>
894where
895    F: Fn(SocketAddr) -> Fut,
896    Fut: Future<Output = std::io::Result<T>> + Send + 'static,
897    T: Send + 'static,
898{
899    // Racing disabled (or a single address): strict order, one at a
900    // time — the pre-DW-030 dial semantics.
901    let Some(delay) = delay.filter(|d| !d.is_zero()) else {
902        let mut last = None;
903        for a in seq {
904            match dial(a).await {
905                Ok(t) => return Ok(t),
906                Err(e) => last = Some(e),
907            }
908        }
909        return Err(last.unwrap_or_else(no_addresses));
910    };
911    let mut set: JoinSet<std::io::Result<T>> = JoinSet::new();
912    let mut it = seq.into_iter().peekable();
913    let mut last_err: Option<std::io::Error> = None;
914    let Some(first) = it.next() else {
915        return Err(no_addresses());
916    };
917    set.spawn(dial(first));
918    let next_start = tokio::time::sleep(delay);
919    tokio::pin!(next_start);
920    loop {
921        tokio::select! {
922            joined = set.join_next() => {
923                match joined.expect("join_next polled on a non-empty JoinSet") {
924                    // First success wins; returning drops `set`, which
925                    // cancels every losing arm mid-connect.
926                    Ok(Ok(t)) => return Ok(t),
927                    Ok(Err(e)) => last_err = Some(e),
928                    // A panicking dial task is a bug, not an address
929                    // being unreachable — surface it loudly.
930                    Err(join_err) => {
931                        return Err(std::io::Error::other(format!("dial task failed: {join_err}")))
932                    }
933                }
934            }
935            _ = &mut next_start, if it.peek().is_some() => {
936                let a = it.next().expect("peek guaranteed an address");
937                set.spawn(dial(a));
938                next_start.as_mut().reset(tokio::time::Instant::now() + delay);
939            }
940        }
941        // RFC 8305 failure fast-forward: with nothing in flight, the
942        // next attempt starts NOW instead of waiting out the delay (the
943        // peer already refused; a delay would only add latency).
944        if set.is_empty() {
945            match it.next() {
946                Some(a) => {
947                    set.spawn(dial(a));
948                    next_start
949                        .as_mut()
950                        .reset(tokio::time::Instant::now() + delay);
951                }
952                None => {
953                    return Err(last_err.take().unwrap_or_else(no_addresses));
954                }
955            }
956        }
957    }
958}
959
960/// The empty-address-list error (both racing modes share it).
961fn no_addresses() -> std::io::Error {
962    std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses to dial")
963}
964
965/// Resolve one endpoint authority and dial it with the RFC 8305 racing
966/// (DW-030). IP-literal authorities skip DNS entirely (the resolver's
967/// short-circuit, the same path `getaddrinfo` takes); a multi-address
968/// resolution races per `delay`. One dial, one outcome: the caller's
969/// health/breaker accounting sees exactly this Result, never the losing
970/// arms' failures. Shared by the pooled connector and the active health
971/// probes (one dialing discipline per upstream).
972pub async fn happy_dial(
973    host: &str,
974    port: u16,
975    delay: Option<Duration>,
976) -> std::io::Result<TcpStream> {
977    let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host, port)).await?.collect();
978    if addrs.is_empty() {
979        return Err(std::io::Error::new(
980            std::io::ErrorKind::AddrNotAvailable,
981            format!("'{host}:{port}' resolved to no addresses"),
982        ));
983    }
984    let seq = interleave_order(&addrs);
985    happy_race(seq, delay, |addr| async move {
986        let stream = TcpStream::connect(addr).await?;
987        // Preserve the pre-DW-030 hyper-util connector behavior: NODELAY
988        // on the proxy hop (latency over throughput on stream request/
989        // response exchanges).
990        let _ = stream.set_nodelay(true);
991        Ok(stream)
992    })
993    .await
994}
995
996/// The cached variant of [`happy_dial`] for the pooled connector (PERF-05,
997/// #170). Resolution runs through the shared [`DnsCache`] (async hickory
998/// with TTL + negative caching) instead of the blocking-pool
999/// `getaddrinfo` of `tokio::net::lookup_host`; the RFC 8305 racing and
1000/// NODELAY behavior are identical. IP-literal hosts skip DNS via the
1001/// cache's short-circuit. The active health probes keep using
1002/// [`happy_dial`] (system resolver) — they are a background, infrequent
1003/// path and do not share the pooled connector's cache.
1004pub async fn happy_dial_via(
1005    dns: &DnsCache,
1006    host: &str,
1007    port: u16,
1008    delay: Option<Duration>,
1009) -> std::io::Result<TcpStream> {
1010    let addrs: Vec<SocketAddr> = dns.resolve(host, port).await?;
1011    if addrs.is_empty() {
1012        return Err(std::io::Error::new(
1013            std::io::ErrorKind::AddrNotAvailable,
1014            format!("'{host}:{port}' resolved to no addresses"),
1015        ));
1016    }
1017    let seq = interleave_order(&addrs);
1018    happy_race(seq, delay, |addr| async move {
1019        let stream = TcpStream::connect(addr).await?;
1020        let _ = stream.set_nodelay(true);
1021        Ok(stream)
1022    })
1023    .await
1024}
1025
1026/// Per-upstream connector: RFC 8305 resolve + dial (DW-030), optional
1027/// rustls TLS with baked-in ALPN, the connect timeout, and the
1028/// connection-cap semaphore.
1029///
1030/// Cloned per pool shard (all fields are `Arc`-shared or `Copy`: the
1031/// connection-cap semaphore, DNS cache, TLS config, and stats are
1032/// shared across shards so the total connection cap and observability
1033/// are preserved).
1034#[derive(Clone)]
1035struct UpstreamConnector {
1036    /// Happy-eyeballs inter-connection delay; None = racing disabled
1037    /// (`timeouts.happy_eyeballs_ms: 0`, strict resolver order).
1038    happy_eyeballs: Option<Duration>,
1039    /// TLS client config (ALPN already set) for https/http2 upstreams.
1040    tls: Option<Arc<rustls::ClientConfig>>,
1041    cap: Arc<Semaphore>,
1042    /// Pending-request cap (`max_pending`, DW-015). Absent = bounded queue
1043    /// of 256 (the #270 default). Some = at most this many requests may
1044    /// WAIT for a connection-cap slot; further dials fail fast with
1045    /// [`UpstreamError::Saturated`].
1046    pending_cap: Option<Arc<Semaphore>>,
1047    connect_timeout: Duration,
1048    stats: Arc<UpstreamStats>,
1049    /// Shared async DNS cache (PERF-05, #170). One cache is shared
1050    /// across every upstream in a registry so repeated dials to the same
1051    /// host hit the TTL/negative cache. Replaces the blocking-pool
1052    /// `getaddrinfo` of `tokio::net::lookup_host` on the pooled dial
1053    /// path.
1054    dns: Arc<DnsCache>,
1055}
1056
1057impl Service<Uri> for UpstreamConnector {
1058    type Response = CappedStream;
1059    type Error = UpstreamError;
1060    type Future =
1061        Pin<Box<dyn Future<Output = Result<CappedStream, UpstreamError>> + Send + 'static>>;
1062
1063    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1064        // Resolution happens inside `call` via the async hickory cache
1065        // (PERF-05); no readiness gating needed.
1066        Poll::Ready(Ok(()))
1067    }
1068
1069    fn call(&mut self, uri: Uri) -> Self::Future {
1070        let happy_eyeballs = self.happy_eyeballs;
1071        let tls = self.tls.clone();
1072        let cap = Arc::clone(&self.cap);
1073        let pending_cap = self.pending_cap.clone();
1074        let connect_timeout = self.connect_timeout;
1075        let stats = Arc::clone(&self.stats);
1076        let dns = Arc::clone(&self.dns);
1077        // Pending admission (DW-015) happens OUTSIDE the async block so a
1078        // saturated upstream rejects immediately: a request that would
1079        // have to queue behind more than `max_pending` waiters never even
1080        // arms its dial. The pending permit is held only while the request
1081        // is WAITING for a connection-cap slot and is dropped the moment
1082        // the cap permit is acquired (the request is then connecting, no
1083        // longer pending).
1084        let _pending = match pending_cap {
1085            Some(pc) => match pc.try_acquire_owned() {
1086                Ok(permit) => Some(permit),
1087                Err(_) => {
1088                    return Box::pin(async { Err(UpstreamError::Saturated) });
1089                }
1090            },
1091            None => None,
1092        };
1093        Box::pin(async move {
1094            // Acquire a cap slot BEFORE dialing. The semaphore is never
1095            // closed, so acquire cannot fail; waiting here is the cap's
1096            // documented backpressure (queue, never fail).
1097            let permit = cap
1098                .acquire_owned()
1099                .await
1100                .expect("connection-cap semaphore is never closed");
1101            // Connection slot acquired: no longer pending. Dropping the
1102            // permit here (before the dial) frees the pending slot for
1103            // the next request.
1104            drop(_pending);
1105            // `Uri::host()` keeps IPv6 brackets ("[::1]"); the resolver
1106            // and rustls `ServerName` want the bare address ("::1").
1107            // hyper-util's GaiResolver stripped these internally — the
1108            // DW-030 dial is ours, so the strip is ours too.
1109            let uri_host = uri.host().unwrap_or_default();
1110            let host = uri_host
1111                .strip_prefix('[')
1112                .and_then(|h| h.strip_suffix(']'))
1113                .unwrap_or(uri_host)
1114                .to_string();
1115            // The authority always carries the configured endpoint port
1116            // (endpoint_authority); the defaults are the inert fallback
1117            // for a hand-built URI.
1118            let port = uri
1119                .port_u16()
1120                .unwrap_or(if tls.is_some() { 443 } else { 80 });
1121            let dial = async {
1122                let tcp = happy_dial_via(&dns, &host, port, happy_eyeballs)
1123                    .await
1124                    .map_err(UpstreamError::Io)?;
1125                let transport = match tls {
1126                    Some(config) => {
1127                        let name = ServerName::try_from(host.clone())
1128                            .map_err(|_| UpstreamError::InvalidHost(host.clone()))?;
1129                        let connector = tokio_rustls::TlsConnector::from(config);
1130                        let tls_stream = connector
1131                            .connect(name, tcp)
1132                            .await
1133                            .map_err(|e| std::io::Error::other(e.to_string()))?;
1134                        Transport::Tls(Box::new(TokioIo::new(tls_stream)))
1135                    }
1136                    None => Transport::Plain(TokioIo::new(tcp)),
1137                };
1138                Ok::<Transport, UpstreamError>(transport)
1139            };
1140            let transport = tokio::time::timeout(connect_timeout, dial)
1141                .await
1142                .map_err(|_| UpstreamError::ConnectTimeout {
1143                    after: connect_timeout,
1144                })??;
1145            stats.connections_opened.fetch_add(1, Ordering::Relaxed);
1146            Ok(CappedStream {
1147                transport,
1148                _permit: permit,
1149            })
1150        })
1151    }
1152}
1153
1154/// A send-capable handle to one configured upstream: a dedicated pooled
1155/// hyper client plus observability. Cheap to share via `Arc` from the
1156/// [`UpstreamRegistry`].
1157pub struct UpstreamHandle {
1158    name: String,
1159    cap: u32,
1160    connect_timeout: Duration,
1161    /// Per-attempt deadline over pool wait + connect + request write +
1162    /// response headers (`timeouts.read_ms`; DW-014). None = unbounded.
1163    read_timeout: Option<Duration>,
1164    /// Response-body inactivity timeout (`timeouts.write_ms`; DW-014).
1165    /// None = unbounded.
1166    write_timeout: Option<Duration>,
1167    /// Resolved retry parameters (`upstreams[].retries`; DW-014).
1168    /// `attempts == 0` means retries off.
1169    retries: RetryParams,
1170    /// Resolved hedge parameters (DW-063); extracted from `retries.hedge`
1171    /// for convenience. `hedge_after == ZERO` means hedging is disabled.
1172    hedge: HedgeParams,
1173    /// Rolling-window retry budget, carried across reloads like the
1174    /// balancer.
1175    retry_budget: Arc<RetryBudget>,
1176    /// Effective pending cap (`max_pending`, DW-015); defaults to 256
1177    /// when absent (#270). 0 = explicitly unbounded (legacy behavior).
1178    max_pending: u32,
1179    /// Per-upstream circuit breaker state (DW-015), carried across reloads
1180    /// like the retry budget. Always present (state-only object); whether
1181    /// the breaker is ENABLED is `breaker_params`.
1182    breaker: Arc<Breaker>,
1183    /// Resolved breaker parameters; None disables the breaker (the proxy
1184    /// then never consults it — behavior identical to pre-DW-015).
1185    breaker_params: Option<BreakerParams>,
1186    /// Resolved happy-eyeballs delay (DW-030); None = racing disabled.
1187    /// Kept on the handle so the active health probes dial with the same
1188    /// discipline as the pooled connector.
1189    happy_eyeballs: Option<Duration>,
1190    stats: Arc<UpstreamStats>,
1191    /// Sharded hyper-util client pool. hyper-util's connection pool uses a
1192    /// single `std::sync::Mutex<PoolInner>` for ALL operations (checkout +
1193    /// return), which becomes the serialization bottleneck under high
1194    /// concurrency (profiled at ~21% of CPU in `__psynch_mutexwait` at
1195    /// 80k RPS with 256 connections). Sharding into N independent clients
1196    /// gives N independent pool mutexes, reducing contention N-fold.
1197    ///
1198    /// Requests are distributed round-robin via `shard_counter` (a relaxed
1199    /// `AtomicUsize`). The connection-cap semaphore lives on the shared
1200    /// `UpstreamConnector`, so the TOTAL connection cap still applies
1201    /// across all shards (the cap is not multiplied by the shard count).
1202    ///
1203    /// Shard count is `DWARA_POOL_SHARDS` (default: available parallelism,
1204    /// capped at 16). 1 shard = the previous single-pool behavior.
1205    clients: Vec<UpstreamClient>,
1206    /// Round-robin shard selector (relaxed: a slight skew under extreme
1207    /// contention is harmless — the goal is distribution, not exactness).
1208    shard_counter: std::sync::atomic::AtomicUsize,
1209    /// Load balancer over this upstream's endpoint set (DW-011); picks
1210    /// the endpoint per dispatch and tracks in-flight counts.
1211    lb: Arc<crate::dataplane::balance::UpstreamLb>,
1212    scheme: &'static str,
1213    http2_only: bool,
1214    /// The trust roots this upstream's TLS connections verify against
1215    /// (#121): None for plaintext `http1` upstreams, the configured
1216    /// `trusted_ca_file` bundle when set, else the webpki public set.
1217    /// Kept on the handle so the active https health probes use the
1218    /// SAME trust as the pooled connector.
1219    tls_roots: Option<rustls::RootCertStore>,
1220    /// DW-108: the configured protocol. `H3` selects the QUIC transport
1221    /// (`h3` field) when the `h3` cargo feature is on, and fails closed
1222    /// (`UpstreamError::H3Unavailable`) when the feature is off (an `h3`
1223    /// upstream is accepted at validation but inert). The other variants
1224    /// use the TCP/TLS `client` below.
1225    protocol: UpstreamProtocol,
1226    /// DP-07 (#236): what to hash for consistent-hash load balancers
1227    /// (`ip_hash` and `maglev`). `None` = client IP (the default).
1228    hash_on: Option<crate::config::HashOn>,
1229    /// DW-108: the H3/QUIC upstream transport, present iff
1230    /// `protocol == H3` AND the `h3` cargo feature is enabled. When
1231    /// `protocol == H3` and this is `None`, the feature is off and every
1232    /// dispatch fails closed with [`UpstreamError::H3Unavailable`].
1233    h3: Option<Arc<crate::dataplane::upstream_h3::H3UpstreamHandle>>,
1234    /// SCALE-10 (#189): number of connections to pre-establish per
1235    /// endpoint on startup/reload. 0 = no pre-warming.
1236    pre_warm: u32,
1237    /// REL-06 (#217): whether the in-flight counter is held until the
1238    /// response body completes (true) or released at header resolution
1239    /// (false, the v1 default).
1240    body_completion_inflight: bool,
1241}
1242
1243impl UpstreamHandle {
1244    /// Upstream name this handle serves.
1245    pub fn name(&self) -> &str {
1246        &self.name
1247    }
1248
1249    /// Effective connection cap (explicit or default, minimum 1).
1250    pub fn cap(&self) -> u32 {
1251        self.cap
1252    }
1253
1254    /// Effective connect timeout (explicit or default).
1255    pub fn connect_timeout(&self) -> Duration {
1256        self.connect_timeout
1257    }
1258
1259    /// Effective happy-eyeballs delay (DW-030); None = racing disabled.
1260    pub fn happy_eyeballs(&self) -> Option<Duration> {
1261        self.happy_eyeballs
1262    }
1263
1264    /// Per-attempt read timeout (`timeouts.read_ms`), if configured.
1265    pub fn read_timeout(&self) -> Option<Duration> {
1266        self.read_timeout
1267    }
1268
1269    /// Response-body inactivity timeout (`timeouts.write_ms`), if
1270    /// configured.
1271    pub fn write_timeout(&self) -> Option<Duration> {
1272        self.write_timeout
1273    }
1274
1275    /// Resolved retry parameters (DW-014). `attempts == 0` = retries off.
1276    pub fn retry_params(&self) -> &RetryParams {
1277        &self.retries
1278    }
1279
1280    /// Resolved hedge parameters (DW-063).
1281    pub fn hedge_params(&self) -> &HedgeParams {
1282        &self.hedge
1283    }
1284
1285    /// This upstream's rolling-window retry budget (DW-014).
1286    pub fn retry_budget(&self) -> &Arc<RetryBudget> {
1287        &self.retry_budget
1288    }
1289
1290    /// Effective pending cap (DW-015); 0 = unbounded queueing.
1291    pub fn max_pending(&self) -> u32 {
1292        self.max_pending
1293    }
1294
1295    /// This upstream's circuit breaker state (DW-015). Always present;
1296    /// consult [`UpstreamHandle::breaker_params`] first — a None there
1297    /// means the breaker is disabled and the state object is dormant.
1298    pub fn breaker(&self) -> &Arc<Breaker> {
1299        &self.breaker
1300    }
1301
1302    /// Resolved breaker parameters (DW-015); None = breaker disabled.
1303    pub fn breaker_params(&self) -> Option<&BreakerParams> {
1304        self.breaker_params.as_ref()
1305    }
1306
1307    /// Total connections established since the handle was built.
1308    pub fn connections_opened(&self) -> u64 {
1309        self.stats.connections_opened.load(Ordering::Relaxed)
1310    }
1311
1312    /// Total requests sent through the pool.
1313    pub fn requests_sent(&self) -> u64 {
1314        self.stats.requests_sent.load(Ordering::Relaxed)
1315    }
1316
1317    /// Effective scheme this upstream dials ("http" or "https").
1318    pub fn scheme(&self) -> &str {
1319        self.scheme
1320    }
1321
1322    /// This upstream's load balancer (endpoint set, algorithm, in-flight
1323    /// counters). Exposed for the TLS-passthrough path (which picks an
1324    /// endpoint the same way) and for tests.
1325    pub fn lb(&self) -> &Arc<crate::dataplane::balance::UpstreamLb> {
1326        &self.lb
1327    }
1328
1329    /// DP-07 (#236): the hash-key source for this upstream's
1330    /// consistent-hash load balancer (`ip_hash` or `maglev`). `None`
1331    /// means the client IP is used (the default).
1332    pub fn hash_on(&self) -> Option<&crate::config::HashOn> {
1333        self.hash_on.as_ref()
1334    }
1335
1336    /// DW-094 (Ent): set the edge's locality context on this upstream's
1337    /// load balancer. Called by the dataplane at startup to propagate
1338    /// the edge's region/zone (from env vars or edge labels) to every
1339    /// upstream's balancer.
1340    pub fn set_locality_context(&self, ctx: crate::config::LocalityContext) {
1341        self.lb.set_locality_context(ctx);
1342    }
1343
1344    /// The trust roots this upstream's TLS connections verify against
1345    /// (#121): None for plaintext `http1` upstreams, the configured
1346    /// `trusted_ca_file` bundle when set, else the webpki public set.
1347    /// Active https health probes clone this so a probe and a proxied
1348    /// request can never disagree about who to trust.
1349    pub fn tls_roots(&self) -> Option<&rustls::RootCertStore> {
1350        self.tls_roots.as_ref()
1351    }
1352
1353    /// DW-108: the H3/QUIC upstream transport, when this upstream is
1354    /// `protocol: h3` AND the `h3` cargo feature is enabled. The QUIC
1355    /// active health probe reuses its TLS client config (trust roots +
1356    /// `h3` ALPN) so a probe and a proxied request trust the same roots.
1357    /// None for non-H3 upstreams or when the feature is off.
1358    pub fn h3_handle(&self) -> Option<&Arc<crate::dataplane::upstream_h3::H3UpstreamHandle>> {
1359        self.h3.as_ref()
1360    }
1361
1362    /// The configured upstream protocol (DW-108). `H3` selects the QUIC
1363    /// transport; the other variants use the TCP/TLS pooled client.
1364    pub fn protocol(&self) -> UpstreamProtocol {
1365        self.protocol
1366    }
1367
1368    /// Send a request through this upstream's pool without a hash key
1369    /// (algorithms other than `ip_hash` ignore the key anyway).
1370    pub async fn send<B>(&self, req: Request<B>) -> Result<Response<UpstreamBody>, UpstreamError>
1371    where
1372        B: hyper::body::Body<Data = bytes::Bytes> + Send + 'static,
1373        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1374    {
1375        self.send_with_hash_key(req, None).await
1376    }
1377
1378    /// Send a request through this upstream's pool, dispatching via the
1379    /// upstream's load balancer (DW-011). `hash_key` is the client IP as a
1380    /// string (used by `ip_hash`; ignored by the other algorithms). The
1381    /// picked endpoint's `address:port` becomes both the dialed URI's
1382    /// authority and the outbound `Host` header (replacing whatever the
1383    /// caller set); the path and query are preserved verbatim. The picked
1384    /// endpoint's in-flight counter is held for the request/response-header
1385    /// exchange (documented approximation: released when headers resolve,
1386    /// not when the streaming body completes). The response body streams
1387    /// (wrapped in [`UpstreamBody`] for the DW-014 write-timeout /
1388    /// mid-body health reporting knobs; the wrapper is a frame-for-frame
1389    /// passthrough when both are unset), so proxying (DW-009) can forward
1390    /// it without buffering.
1391    pub async fn send_with_hash_key<B>(
1392        &self,
1393        req: Request<B>,
1394        hash_key: Option<&str>,
1395    ) -> Result<Response<UpstreamBody>, UpstreamError>
1396    where
1397        B: hyper::body::Body<Data = bytes::Bytes> + Send + 'static,
1398        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1399    {
1400        let mut picked = None;
1401        self.send_inner(req, hash_key, &mut picked)
1402            .await
1403            .map(|(resp, ())| resp)
1404    }
1405
1406    /// [`UpstreamHandle::send_with_hash_key`] with observability
1407    /// (DW-021): the load-balancer pick runs under an `upstream_pick`
1408    /// span (child of the caller's `upstream_attempt` span) and the
1409    /// picked endpoint's `address:port` is written to `picked` when a
1410    /// dispatch resolved (left None on the NoEndpoints guard), so the
1411    /// proxy can attribute the attempt in its access log and the
1412    /// `upstream_attempts_total` metric.
1413    pub async fn send_with_hash_key_observed<B>(
1414        &self,
1415        req: Request<B>,
1416        hash_key: Option<&str>,
1417        picked: &mut Option<String>,
1418    ) -> Result<Response<UpstreamBody>, UpstreamError>
1419    where
1420        B: hyper::body::Body<Data = bytes::Bytes> + Send + 'static,
1421        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1422    {
1423        self.send_inner(req, hash_key, picked)
1424            .await
1425            .map(|(resp, ())| resp)
1426    }
1427
1428    async fn send_inner<B>(
1429        &self,
1430        mut req: Request<B>,
1431        hash_key: Option<&str>,
1432        picked: &mut Option<String>,
1433    ) -> Result<(Response<UpstreamBody>, ()), UpstreamError>
1434    where
1435        B: hyper::body::Body<Data = bytes::Bytes> + Send + 'static,
1436        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1437    {
1438        // PERF: clone the PathAndQuery directly from the request URI
1439        // instead of allocating a String and re-parsing it. PathAndQuery
1440        // is internally an Arc<str>-like type, so clone is a refcount
1441        // bump. The String form is derived via as_str() only where needed
1442        // (the H3 branch's format!()).
1443        let path = req
1444            .uri()
1445            .path_and_query()
1446            .cloned()
1447            .unwrap_or_else(|| PathAndQuery::from_static("/"));
1448        // DW-157: a plugin's `:authority` rewrite, staged by the proxy's
1449        // forward build as the Host value the forwarded request should
1450        // carry. The dial target and the URI authority stay the picked
1451        // endpoint's (a plugin shapes the Host header, never which
1452        // origin the gateway dials); applied per dispatch below, pooled
1453        // and H3 alike.
1454        let plugin_host = req
1455            .extensions()
1456            .get::<crate::dataplane::plugin_dispatch::PluginForwardHost>()
1457            .map(|host| host.0.clone());
1458        // Guard rather than dialing a fabricated address: empty endpoint
1459        // lists are only possible via unvalidated construction. The pick,
1460        // endpoint resolution, and in-flight acquisition all run against
1461        // ONE state snapshot (pick_for_dispatch), so a concurrent reload
1462        // cannot detach the guard from the picked endpoint.
1463        let mut dispatch = {
1464            // DW-021: the pick phase is its own span so a full trace
1465            // shows pick separately from the attempt that contains it.
1466            let span = tracing::info_span!(
1467                "upstream_pick",
1468                upstream = self.name,
1469                endpoint = tracing::field::Empty
1470            );
1471            let _guard = span.enter();
1472            let dispatch = self
1473                .lb
1474                .pick_for_dispatch(hash_key)
1475                .ok_or(UpstreamError::NoEndpoints)?;
1476            // PERF: the authority (and its parsed forms) are precomputed
1477            // per endpoint at LB-state build — no per-attempt format or
1478            // parse. One owned String is retained for the caller's
1479            // picked-endpoint label.
1480            span.record("endpoint", &*dispatch.authority);
1481            *picked = Some(dispatch.authority.to_string());
1482            dispatch
1483        };
1484        // DW-108: H3 upstreams dispatch over QUIC, not the TCP/TLS pooled
1485        // client. The LB pick, in-flight guard, health reporting, and
1486        // latency recording are SHARED with the legacy path (they operate
1487        // on endpoint addresses, not the transport); only the dial +
1488        // request/response exchange differs. When the `h3` cargo feature
1489        // is off (or the H3 handle failed to build), the upstream is
1490        // inert and every dispatch fails closed with
1491        // [`UpstreamError::H3Unavailable`] — never a silent fallback to a
1492        // wrong transport.
1493        if matches!(self.protocol, UpstreamProtocol::H3) {
1494            let issued = std::time::Instant::now();
1495            let outcome: Result<hyper::Response<Bytes>, UpstreamError> = {
1496                {
1497                    if let Some(h3) = &self.h3 {
1498                        // Buffer the request body (the H3 path sends it as
1499                        // one DATA frame; see `dataplane::upstream_h3`).
1500                        let method = req.method().clone();
1501                        let mut headers = req.headers().clone();
1502                        // The plugin's `:authority` rewrite applies to the
1503                        // Host header here too (the H3 request's own
1504                        // authority pseudo-header is the dialed endpoint,
1505                        // per the note above).
1506                        if let Some(host) = plugin_host.clone() {
1507                            headers.insert(hyper::header::HOST, host);
1508                        }
1509                        let body_bytes = match req.into_body().collect().await {
1510                            Ok(collected) => collected.to_bytes(),
1511                            Err(e) => {
1512                                let err = UpstreamError::Io(std::io::Error::other(e));
1513                                if let Some(health) = &dispatch.health {
1514                                    health.report(self.lb.now_ms(), true);
1515                                }
1516                                self.lb.record_latency(dispatch.idx, issued.elapsed());
1517                                dispatch.release();
1518                                return Err(err);
1519                            }
1520                        };
1521                        let mut h3_req = hyper::Request::builder().method(method).uri(format!(
1522                            "https://{}{}",
1523                            dispatch.authority,
1524                            path.as_str()
1525                        ));
1526                        if let Some(h) = h3_req.headers_mut() {
1527                            *h = headers;
1528                        }
1529                        // H3 speaks HTTP/3; stamp the version so the
1530                        // upstream sees its own dialect.
1531                        h3_req = h3_req.version(hyper::Version::HTTP_3);
1532                        let h3_req = h3_req.body(body_bytes).expect("static builder");
1533                        h3.send(&dispatch.address, dispatch.port, &dispatch.address, h3_req)
1534                            .await
1535                    } else {
1536                        Err(UpstreamError::H3Unavailable)
1537                    }
1538                }
1539            };
1540            // Same health classification as the legacy path: transport
1541            // errors and statuses >= 5xx are failures; admission/config
1542            // errors (H3Unavailable) are not health-reportable.
1543            let report = match &outcome {
1544                Ok(resp) => Some(resp.status().as_u16() >= 500),
1545                Err(err) if health_reportable(err) => Some(true),
1546                Err(_) => None,
1547            };
1548            if let (Some(health), Some(is_failure)) = (&dispatch.health, report) {
1549                health.report(self.lb.now_ms(), is_failure);
1550            }
1551            self.lb.record_latency(dispatch.idx, issued.elapsed());
1552            dispatch.release();
1553            // The H3 path returns a buffered body (UpstreamBody::from_buffered);
1554            // the non-H3 path returns H3Unavailable (an Err), so the map
1555            // only runs when the feature is on and the request succeeded.
1556            return outcome.map(|resp| (resp.map(UpstreamBody::from_buffered), ()));
1557        }
1558        // Held (inside `dispatch`) until the response (headers) resolves;
1559        // see the doc comment.
1560        //
1561        // PERF: construct the Uri from parts instead of format!() + parse.
1562        // The previous code allocated a String for the full URI
1563        // ("scheme://authority/path") and then parsed it; from_parts
1564        // reuses the precomputed authority (a refcount clone from the
1565        // LB endpoint row — no per-attempt parse) and the cloned
1566        // PathAndQuery (an Arc-bump clone from the inbound URI), avoiding
1567        // the intermediate full-URI String allocation and the full-URI
1568        // parse pass.
1569        let uri = {
1570            let scheme = Scheme::try_from(self.scheme)
1571                .map_err(|e| UpstreamError::Io(std::io::Error::other(e.to_string())))?;
1572            // The precomputed parse fails only for a mis-configured
1573            // endpoint address that validation already rejects; fall back
1574            // to parsing the authority string (the pre-optimization
1575            // behavior) rather than inventing a new failure mode.
1576            let auth = match dispatch.http_authority.clone() {
1577                Some(auth) => auth,
1578                None => Authority::from_str(&dispatch.authority)
1579                    .map_err(|e| UpstreamError::Io(std::io::Error::other(e.to_string())))?,
1580            };
1581            let mut parts = http::uri::Parts::default();
1582            parts.scheme = Some(scheme);
1583            parts.authority = Some(auth);
1584            parts.path_and_query = Some(path);
1585            Uri::from_parts(parts)
1586                .map_err(|e| UpstreamError::Io(std::io::Error::other(e.to_string())))
1587        }?;
1588        *req.uri_mut() = uri;
1589        // The gateway, not the client, names the origin it dials: the
1590        // picked endpoint's authority replaces any Host the caller set —
1591        // EXCEPT a plugin's `:authority` rewrite (DW-157), which names
1592        // the Host header the upstream should see while the dial target
1593        // stays the balancer's pick. PERF: the HeaderValue is
1594        // precomputed with the authority; the fallback (and the
1595        // skip-on-failure shape) mirrors the Uri note above —
1596        // unreachable for validated configs.
1597        let host = plugin_host.or_else(|| {
1598            dispatch
1599                .host_value
1600                .clone()
1601                .or_else(|| hyper::header::HeaderValue::from_str(&dispatch.authority).ok())
1602        });
1603        if let Some(v) = host {
1604            req.headers_mut().insert(hyper::header::HOST, v);
1605        }
1606        // Normalize the HTTP version for the pool's protocol: an inbound h2
1607        // request proxied to an h1 upstream must be downgraded to 1.1 (and
1608        // vice versa); the pooled client speaks exactly one dialect.
1609        *req.version_mut() = if self.http2_only {
1610            Version::HTTP_2
1611        } else {
1612            Version::HTTP_11
1613        };
1614        self.stats.requests_sent.fetch_add(1, Ordering::Relaxed);
1615        let req =
1616            req.map(|b| http_body_util::combinators::UnsyncBoxBody::new(b.map_err(Into::into)));
1617        // Observation wire (DW-012): the outcome is reported to the picked
1618        // endpoint's health tracker when the response headers resolve —
1619        // the same point the in-flight guard releases. Classification:
1620        // transport errors (the Err arm: connect timeout, refusal, reset,
1621        // framing, and the DW-014 read timeout) and statuses >= 500 are
1622        // failures; 1xx-4xx (including 429/408; documented choice) are
1623        // successes. Mid-BODY aborts are reported later, by `UpstreamBody`.
1624        // DW-090: the issued instant is captured here so the peak-EWMA
1625        // latency tracker records the FULL attempt duration (pool-queue
1626        // wait + connect + request write + response headers) for every
1627        // outcome — success, error, and timeout alike (a timeout is a
1628        // latency signal too).
1629        let issued = std::time::Instant::now();
1630        // Round-robin shard selection: fetch_add gives each request the
1631        // next shard index. Relaxed ordering is sufficient — the goal is
1632        // distribution, not a strict ordering guarantee.
1633        let shard_idx = self.shard_counter.fetch_add(1, Ordering::Relaxed) % self.clients.len();
1634        let request = self.clients[shard_idx].request(req);
1635        // Captured before release(): the body wrapper reports mid-stream
1636        // failures into the same tracker (DW-014 closing the DW-012 gap).
1637        let body_health = dispatch.health.clone().map(|hd| (Arc::clone(&self.lb), hd));
1638        let outcome = match self.read_timeout {
1639            // Per-attempt deadline (DW-014): pool-queue wait + connect +
1640            // request write + response headers, one bound. Dropping the
1641            // request future cancels the attempt (the pooled connection,
1642            // if one was involved, is discarded by the pool).
1643            Some(after) => match tokio::time::timeout(after, request).await {
1644                Ok(outcome) => outcome,
1645                Err(_elapsed) => {
1646                    if let Some(health) = &dispatch.health {
1647                        health.report(self.lb.now_ms(), true);
1648                    }
1649                    // DW-090: record the latency (the full timeout
1650                    // duration) before releasing the in-flight guard.
1651                    self.lb.record_latency(dispatch.idx, issued.elapsed());
1652                    dispatch.release();
1653                    return Err(UpstreamError::ReadTimeout { after });
1654                }
1655            },
1656            None => request.await,
1657        };
1658        // Client-side admission rejections (Saturated) and configuration-
1659        // class errors say nothing about the PICKED endpoint's health and
1660        // must not eject it; only genuine transport/exchange outcomes
1661        // report (same classification as the breaker wire in the proxy).
1662        let report = match &outcome {
1663            Ok(resp) => Some(resp.status().as_u16() >= 500),
1664            Err(err) if health_reportable_legacy(err) => Some(true),
1665            Err(_) => None,
1666        };
1667        if let (Some(health), Some(is_failure)) = (&dispatch.health, report) {
1668            health.report(self.lb.now_ms(), is_failure);
1669        }
1670        // DW-090: record the observed latency for the peak-EWMA tracker.
1671        // Recorded for ALL outcomes (success, error, timeout) since even
1672        // a timeout is a latency signal. No-op when the algorithm is not
1673        // peak_ewma (the tracker is absent).
1674        self.lb.record_latency(dispatch.idx, issued.elapsed());
1675        // REL-06 (#217): when body-completion in-flight accounting is
1676        // enabled, move the in-flight guard to the response body so
1677        // the counter is held until the body stream completes. On
1678        // error paths, the guard is released normally (the body never
1679        // starts).
1680        let body_guard = if self.body_completion_inflight {
1681            dispatch.take_guard()
1682        } else {
1683            None
1684        };
1685        dispatch.release();
1686        outcome.map_err(Into::into).map(|resp| {
1687            (
1688                resp.map(|inner| {
1689                    let mut body =
1690                        UpstreamBody::from_incoming(inner, self.write_timeout, body_health);
1691                    if let Some(guard) = body_guard {
1692                        body.set_inflight_guard(guard);
1693                    }
1694                    body
1695                }),
1696                (),
1697            )
1698        })
1699    }
1700}
1701
1702impl std::fmt::Debug for UpstreamHandle {
1703    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1704        f.debug_struct("UpstreamHandle")
1705            .field("name", &self.name)
1706            .field("cap", &self.cap)
1707            .field("connect_timeout", &self.connect_timeout)
1708            .field("scheme", &self.scheme)
1709            .finish_non_exhaustive()
1710    }
1711}
1712
1713/// Public for testing the connection-cap clamping contract.
1714pub fn effective_cap(u: &Upstream) -> u32 {
1715    // Validated configs always carry cap >= 1, but a directly constructed
1716    // (unvalidated) Gateway can bypass that; a zero-permit semaphore would
1717    // hang every dial forever. Clamp so misuse degrades to serial.
1718    u.connection_cap.unwrap_or(DEFAULT_CONNECTION_CAP).max(1)
1719}
1720
1721fn effective_connect_timeout(u: &Upstream) -> Duration {
1722    Duration::from_millis(
1723        u.timeouts
1724            .as_ref()
1725            .and_then(|t: &Timeouts| t.connect_ms)
1726            .unwrap_or(DEFAULT_CONNECT_TIMEOUT_MS),
1727    )
1728}
1729
1730/// `timeouts.read_ms`, resolved to a Duration. None leaves the
1731/// request/headers exchange unbounded (DW-014 enforcement is opt-in by
1732/// configuring the knob).
1733fn effective_read_timeout(u: &Upstream) -> Option<Duration> {
1734    u.timeouts
1735        .as_ref()
1736        .and_then(|t| t.read_ms)
1737        .map(Duration::from_millis)
1738}
1739
1740/// `timeouts.write_ms`, resolved to a Duration (response-body inactivity
1741/// bound; DW-014).
1742fn effective_write_timeout(u: &Upstream) -> Option<Duration> {
1743    u.timeouts
1744        .as_ref()
1745        .and_then(|t| t.write_ms)
1746        .map(Duration::from_millis)
1747}
1748
1749fn effective_slow_start(u: &Upstream) -> Duration {
1750    Duration::from_millis(u.slow_start_ms.unwrap_or(0).min(MAX_SLOW_START_MS))
1751}
1752
1753fn build_handle(
1754    u: &Upstream,
1755    root_store: rustls::RootCertStore,
1756    previous: Option<&Arc<UpstreamHandle>>,
1757    events: Option<&crate::events::Emitter>,
1758    dns: Arc<DnsCache>,
1759) -> Arc<UpstreamHandle> {
1760    let cap = effective_cap(u);
1761    let connect_timeout = effective_connect_timeout(u);
1762    let stats = Arc::new(UpstreamStats::default());
1763    // DW-044: one upstream-labeled emitter for this handle's state
1764    // machines — the breaker's transitions and (via the balancer) the
1765    // endpoint trackers' ejection/recovery events.
1766    let upstream_events = events.map(|em| em.for_upstream(&u.name));
1767
1768    // DW-030: the connector resolves + dials itself (RFC 8305 happy
1769    // eyeballs, `timeouts.happy_eyeballs_ms`); there is no hyper-util
1770    // HttpConnector to configure anymore.
1771
1772    // `root_store` is the trust for THIS upstream (#121): the configured
1773    // trusted_ca_file bundle when set, else webpki (+ any programmatic
1774    // extras). Kept on the handle so probes share it (tls_roots).
1775    let (scheme, tls, http2_only, tls_roots): (&'static str, Option<_>, bool, Option<_>) = match u
1776        .protocol
1777    {
1778        UpstreamProtocol::Http1 => ("http", None, false, None),
1779        UpstreamProtocol::H2c => {
1780            // DP-02 (#235): cleartext HTTP/2 with prior knowledge. No
1781            // TLS; the client speaks HTTP/2 directly over TCP. Common
1782            // for internal east-west gRPC traffic without TLS.
1783            ("http", None, true, None)
1784        }
1785        UpstreamProtocol::Https => {
1786            // SEC-04 / DW-109: when cert_pinning is configured AND
1787            // the `cert_pinning` cargo feature is ON, install the
1788            // SPKI pin verifier (fail-closed, no CA fallback).
1789            // Otherwise use the normal CA-based config.
1790            if let Some(verifier) = crate::security::cert_pinning::CertPinVerifier::from_upstream(u)
1791            {
1792                let cfg = crate::security::cert_pinning::client_config_with_pinning(
1793                    Arc::new(verifier),
1794                    b"http/1.1",
1795                );
1796                ("https", Some(Arc::new(cfg)), false, Some(root_store))
1797            } else {
1798                // SEC-03: mTLS client cert when configured.
1799                let cfg = match &u.mtls {
1800                        Some(mtls) => crate::security::tls::https_h1_client_config_with_auth(
1801                            root_store.clone(),
1802                            &mtls.client_cert_file,
1803                            &mtls.client_key_file,
1804                        )
1805                        .unwrap_or_else(|e| {
1806                            tracing::error!(
1807                                code = "upstream_mtls_load_failed",
1808                                upstream = %u.name,
1809                                "upstream mtls cert/key load failed: {e}; falling back to no client auth"
1810                            );
1811                            crate::security::tls::https_h1_client_config_pq(
1812                                root_store.clone(),
1813                                u.pq,
1814                            )
1815                        }),
1816                        None => crate::security::tls::https_h1_client_config_pq(
1817                            root_store.clone(),
1818                            u.pq,
1819                        ),
1820                    };
1821                ("https", Some(Arc::new(cfg)), false, Some(root_store))
1822            }
1823        }
1824        UpstreamProtocol::Http2 => {
1825            // Same roots as https, but ALPN h2 and a client locked to
1826            // HTTP/2 (see module docs). DW-105: when the upstream opts
1827            // in to PQ hybrid key exchange (`pq: true`), prepend the
1828            // hybrid kx group before building the config (experimental
1829            // no-op when the rustls PQ API is not reachable).
1830            if let Some(verifier) = crate::security::cert_pinning::CertPinVerifier::from_upstream(u)
1831            {
1832                let cfg = crate::security::cert_pinning::client_config_with_pinning(
1833                    Arc::new(verifier),
1834                    b"h2",
1835                );
1836                ("https", Some(Arc::new(cfg)), true, Some(root_store))
1837            } else {
1838                if u.pq {
1839                    let _ = crate::security::pq::install_pq_kx_group();
1840                }
1841                // SEC-03: mTLS client cert when configured.
1842                let cfg = match &u.mtls {
1843                        Some(mtls) => crate::security::tls::https_h2_client_config_with_auth(
1844                            root_store.clone(),
1845                            &mtls.client_cert_file,
1846                            &mtls.client_key_file,
1847                        )
1848                        .unwrap_or_else(|e| {
1849                            tracing::error!(
1850                                code = "upstream_mtls_load_failed",
1851                                upstream = %u.name,
1852                                "upstream mtls cert/key load failed: {e}; falling back to no client auth"
1853                            );
1854                            let mut c = rustls::ClientConfig::builder()
1855                                .with_root_certificates(root_store.clone())
1856                                .with_no_client_auth();
1857                            c.alpn_protocols = vec![b"h2".to_vec()];
1858                            c
1859                        }),
1860                        None => {
1861                            let mut c = rustls::ClientConfig::builder()
1862                                .with_root_certificates(root_store.clone())
1863                                .with_no_client_auth();
1864                            c.alpn_protocols = vec![b"h2".to_vec()];
1865                            c
1866                        }
1867                    };
1868                ("https", Some(Arc::new(cfg)), true, Some(root_store))
1869            }
1870        }
1871        // DW-108: H3 dials QUIC, not the TCP/TLS pooled client. The
1872        // legacy `client` below is built but never used for an H3
1873        // upstream (send_inner dispatches to the `h3` handle). scheme
1874        // is "https" so URI/authority construction is shared; the
1875        // trust roots are kept so the QUIC active health probe shares
1876        // trust with the H3 connector (#121).
1877        UpstreamProtocol::H3 => ("https", None, false, Some(root_store)),
1878    };
1879
1880    // DW-108: build the H3/QUIC transport for an `h3` upstream (feature
1881    // on). A build failure (the quinn client endpoint cannot bind an
1882    // ephemeral port, or the rustls config is rejected by QUIC) is a
1883    // torn-state the operator must see: log loudly and leave the handle
1884    // inert (h3 = None) so every dispatch fails closed with
1885    // `UpstreamError::H3Unavailable` rather than half-working.
1886    let h3_handle: Option<Arc<crate::dataplane::upstream_h3::H3UpstreamHandle>> =
1887        if matches!(u.protocol, UpstreamProtocol::H3) {
1888            match crate::dataplane::upstream_h3::H3UpstreamHandle::new(
1889                u.name.clone(),
1890                tls_roots
1891                    .clone()
1892                    .unwrap_or_else(rustls::RootCertStore::empty),
1893                connect_timeout,
1894                cap,
1895                effective_read_timeout(u),
1896                Arc::clone(&stats),
1897            ) {
1898                Ok(h) => Some(Arc::new(h)),
1899                Err(err) => {
1900                    tracing::error!(
1901                        code = "upstream_h3_build_failed",
1902                        upstream = %u.name,
1903                        "h3 upstream transport build failed; failing closed (every dispatch will \
1904                         return H3Unavailable): {err}"
1905                    );
1906                    None
1907                }
1908            }
1909        } else {
1910            None
1911        };
1912
1913    let connector = UpstreamConnector {
1914        happy_eyeballs: effective_happy_eyeballs(u),
1915        tls,
1916        cap: Arc::new(Semaphore::new(cap as usize)),
1917        pending_cap: u
1918            .max_pending
1919            .filter(|p| *p > 0)
1920            .or(Some(DEFAULT_MAX_PENDING))
1921            .map(|p| Arc::new(Semaphore::new(p as usize))),
1922        connect_timeout,
1923        stats: Arc::clone(&stats),
1924        dns,
1925    };
1926
1927    // Pool sharding: build N independent hyper-util clients, each with its
1928    // own `Mutex<PoolInner>`. hyper-util's pool lock is the dominant
1929    // serialization bottleneck under high concurrency (profiled at ~21% of
1930    // CPU in mutex waits at 80k RPS). Sharding distributes the lock N ways.
1931    // The connector is cloned per shard — all shared state (connection-cap
1932    // semaphore, DNS cache, TLS config, stats) is `Arc`-shared, so the
1933    // TOTAL connection cap still applies across all shards.
1934    let pool_shards = pool_shard_count();
1935    let build_one_client = || {
1936        let mut builder = Client::builder(TokioExecutor::new());
1937        if http2_only {
1938            builder.http2_only(true);
1939        }
1940        // The pool timer is ALWAYS installed: it is required for
1941        // `pool_idle_timeout` eviction to fire (hyper-util silently no-ops
1942        // idle-timeout without a timer). DP-03: the optional `pool` block
1943        // layers operator-tuned knobs on top of this baseline; omitted knobs
1944        // keep hyper-util's built-in defaults (the prior behavior).
1945        builder.pool_timer(TokioTimer::new());
1946        // The h2 timer is likewise ALWAYS installed: hyper requires it to
1947        // schedule HTTP/2 keep-alive PINGs, and panics ("You must supply a
1948        // timer") on the first h2 connection when
1949        // `http2_keep_alive_interval` is set without one. Installing it
1950        // unconditionally is harmless (it is inert when no PING interval is
1951        // configured) and matches the pool-timer pattern.
1952        builder.timer(TokioTimer::new());
1953        if let Some(p) = &u.pool {
1954            // Pool knobs apply to every protocol (h1 and h2 reuse the same
1955            // hyper-util connection pool).
1956            // SCALE-10 (#189): max_connection_age_ms sets the pool idle
1957            // timeout to the minimum of itself and pool_idle_timeout_ms,
1958            // so connections are evicted before they become stale.
1959            let idle_timeout = match (p.pool_idle_timeout_ms, p.max_connection_age_ms) {
1960                (Some(a), Some(b)) => Some(Duration::from_millis(a.min(b))),
1961                (Some(a), None) => Some(Duration::from_millis(a)),
1962                (None, Some(b)) => Some(Duration::from_millis(b)),
1963                (None, None) => None,
1964            };
1965            if let Some(d) = idle_timeout {
1966                builder.pool_idle_timeout(Some(d));
1967            }
1968            if let Some(n) = p.pool_max_idle_per_host {
1969                builder.pool_max_idle_per_host(n as usize);
1970            }
1971            // HTTP/2 knobs are only effective on an h2 client, but hyper-util
1972            // accepts them on the builder regardless (they are inert when no
1973            // h2 connection is negotiated), so they are wired unconditionally
1974            // rather than gated on `http2_only`.
1975            if let Some(ms) = p.http2_keep_alive_interval_ms {
1976                builder.http2_keep_alive_interval(Some(Duration::from_millis(ms)));
1977            }
1978            if let Some(ms) = p.http2_keep_alive_timeout_ms {
1979                builder.http2_keep_alive_timeout(Duration::from_millis(ms));
1980            }
1981            if p.http2_adaptive_window {
1982                builder.http2_adaptive_window(true);
1983            }
1984            if let Some(n) = p.max_concurrent_streams {
1985                // hyper-util exposes the client-side send-concurrency cap as
1986                // `http2_initial_max_send_streams` (the h2 client's own
1987                // stream limiter, distinct from the SETTINGS value the client
1988                // advertises to the server). This is the knob operators mean
1989                // when they ask to "tune max_concurrent_streams" for p99.
1990                builder.http2_initial_max_send_streams(n as usize);
1991            }
1992            // PERF-11 (#244): per-upstream h2 flow-control window sizes.
1993            if let Some(sz) = p.http2_initial_stream_window_size {
1994                builder.http2_initial_stream_window_size(sz);
1995            }
1996            if let Some(sz) = p.http2_initial_connection_window_size {
1997                builder.http2_initial_connection_window_size(sz);
1998            }
1999        }
2000        builder.build(connector.clone())
2001    };
2002    let clients = (0..pool_shards)
2003        .map(|_| build_one_client())
2004        .collect::<Vec<_>>();
2005
2006    // Hot-swap: an existing balancer for this upstream name keeps its
2007    // live state (in-flight counters, WRR phase, slow-start clocks,
2008    // passive-health trackers) for unchanged endpoint addresses; a fresh
2009    // one starts clean. The retry budget is carried the same way (the
2010    // rolling window survives a reload), while the retry PARAMETERS apply
2011    // from the new config.
2012    let previous_lb = previous.map(|h| h.lb());
2013    let lb = match previous_lb {
2014        Some(prev) => {
2015            prev.rebuild_with_health_and_events(
2016                &u.endpoints,
2017                u.load_balancer,
2018                effective_slow_start(u),
2019                u.health.as_ref(),
2020                upstream_events.as_ref(),
2021                u.peak_ewma.as_ref(),
2022                u.locality.as_ref(),
2023            );
2024            Arc::clone(prev)
2025        }
2026        None => crate::dataplane::balance::UpstreamLb::new_with_health_and_events(
2027            &u.endpoints,
2028            u.load_balancer,
2029            effective_slow_start(u),
2030            u.health.as_ref(),
2031            upstream_events.as_ref(),
2032            u.peak_ewma.as_ref(),
2033            u.locality.as_ref(),
2034        ),
2035    };
2036    let retry_budget = previous
2037        .map(|h| Arc::clone(h.retry_budget()))
2038        .unwrap_or_else(|| Arc::new(RetryBudget::new()));
2039    // Breaker state carries across reloads; PARAMETERS apply from the new
2040    // config (the RetryBudget/RetryParams split, verbatim). A carried
2041    // breaker keeps its emitter binding (state-only object; the emitter
2042    // is per-dataplane and stable); a fresh one binds it here (DW-044).
2043    let breaker =
2044        previous
2045            .map(|h| Arc::clone(h.breaker()))
2046            .unwrap_or_else(|| match &upstream_events {
2047                Some(events) => Arc::new(Breaker::with_clock_and_events(
2048                    system_now_ms_for_breaker,
2049                    Some(events.clone()),
2050                )),
2051                None => Arc::new(Breaker::new()),
2052            });
2053
2054    Arc::new(UpstreamHandle {
2055        name: u.name.clone(),
2056        cap,
2057        connect_timeout,
2058        read_timeout: effective_read_timeout(u),
2059        write_timeout: effective_write_timeout(u),
2060        retries: RetryParams::from_config(u.retries.as_ref()),
2061        hedge: HedgeParams::from_config(u.retries.as_ref().and_then(|r| r.hedge.as_ref())),
2062        retry_budget,
2063        max_pending: u
2064            .max_pending
2065            .filter(|p| *p > 0)
2066            .unwrap_or(DEFAULT_MAX_PENDING),
2067        breaker,
2068        breaker_params: u.breaker.as_ref().map(BreakerParams::from_config),
2069        happy_eyeballs: effective_happy_eyeballs(u),
2070        stats,
2071        clients,
2072        shard_counter: std::sync::atomic::AtomicUsize::new(0),
2073        lb,
2074        scheme,
2075        http2_only,
2076        tls_roots,
2077        protocol: u.protocol,
2078        hash_on: u.hash_on.clone(),
2079        h3: h3_handle,
2080        pre_warm: u.pool.as_ref().and_then(|p| p.pre_warm).unwrap_or(0),
2081        body_completion_inflight: u
2082            .health
2083            .as_ref()
2084            .is_some_and(|h| h.body_completion_inflight),
2085    })
2086}
2087
2088/// Registry of per-upstream pooled clients, built from one published
2089/// snapshot. Rebuild (and drop the old registry) on snapshot swap; in-flight
2090/// requests keep their old pools until their handles are dropped.
2091#[derive(Default)]
2092pub struct UpstreamRegistry {
2093    handles: BTreeMap<String, Arc<UpstreamHandle>>,
2094    /// Compiled service splits (DW-040), keyed by service name. Empty
2095    /// unless a service carries a `split` block; single-target
2096    /// services resolve through `handles` as ever.
2097    splits: BTreeMap<String, Arc<crate::dataplane::split::ServiceSplit>>,
2098}
2099
2100impl UpstreamRegistry {
2101    /// Build from a snapshot, verifying upstream TLS certificates against
2102    /// the Mozilla webpki root set — except upstreams that configure
2103    /// `trusted_ca_file`, which verify against their own bundle (#121).
2104    pub fn from_snapshot(snapshot: &Snapshot) -> Self {
2105        Self::with_root_certificates(snapshot, &[])
2106            .expect("registry build without extra roots cannot fail")
2107    }
2108
2109    /// Build from a snapshot, carrying over per-upstream load-balancer
2110    /// state from `previous` (in-flight counters, WRR phase, slow-start
2111    /// clocks for unchanged endpoint addresses). This is the reload path:
2112    /// weight and endpoint changes take effect without a restart, and
2113    /// in-flight requests holding old handles are unaffected.
2114    pub fn from_snapshot_with_previous(snapshot: &Snapshot, previous: &UpstreamRegistry) -> Self {
2115        Self::with_root_certificates_and_previous(snapshot, &[], Some(previous))
2116            .expect("registry build without extra roots cannot fail")
2117    }
2118
2119    /// `from_snapshot` with the dataplane's event emitter attached
2120    /// (DW-044): breaker transitions and endpoint ejection/recovery in
2121    /// this registry's state machines emit onto the bus. Distinct
2122    /// constructors rather than a changed signature so the many
2123    /// event-agnostic call sites (tests, tooling) stay untouched.
2124    pub fn from_snapshot_with_events(
2125        snapshot: &Snapshot,
2126        events: Option<&crate::events::Emitter>,
2127    ) -> Self {
2128        Self::with_root_certificates_previous_and_events(snapshot, &[], None, events)
2129            .expect("registry build without extra roots cannot fail")
2130    }
2131
2132    /// `from_snapshot_with_previous` with the dataplane's event emitter
2133    /// attached (DW-044); the dataplane's reload path.
2134    pub fn from_snapshot_with_previous_and_events(
2135        snapshot: &Snapshot,
2136        previous: &UpstreamRegistry,
2137        events: Option<&crate::events::Emitter>,
2138    ) -> Self {
2139        Self::with_root_certificates_previous_and_events(snapshot, &[], Some(previous), events)
2140            .expect("registry build without extra roots cannot fail")
2141    }
2142
2143    /// Build from a snapshot with EXTRA trusted root certificates (e.g. a
2144    /// private CA signing upstream certificates), added on top of the
2145    /// webpki roots. Fails if any extra root is malformed, so operators
2146    /// see the bad certificate at registry build time rather than as a
2147    /// mysterious handshake failure later.
2148    pub fn with_root_certificates(
2149        snapshot: &Snapshot,
2150        extra_roots: &[CertificateDer<'_>],
2151    ) -> Result<Self, UpstreamError> {
2152        Self::with_root_certificates_and_previous(snapshot, extra_roots, None)
2153    }
2154
2155    /// `with_root_certificates` with optional balancer-state carry-over
2156    /// from a previous registry build (see
2157    /// [`UpstreamRegistry::from_snapshot_with_previous`]).
2158    ///
2159    /// Per-upstream `trusted_ca_file` (#121): when set, that upstream's
2160    /// connector trusts the file's bundle INSTEAD of the webpki+extras
2161    /// store — a private-CA upstream must not implicitly keep public-root
2162    /// trust. Config-driven bundles that fail to load at build time do
2163    /// NOT fail the build (the config was file-validated at publish, so
2164    /// this is a torn state — the bundle was replaced/unlinked
2165    /// underneath the gateway); the upstream FAILS CLOSED with an empty
2166    /// root store and a loud error log, so no traffic is ever sent under
2167    /// trust the operator did not configure. Programmatic `extra_roots`
2168    /// keep the immediate-Err contract above.
2169    pub fn with_root_certificates_and_previous(
2170        snapshot: &Snapshot,
2171        extra_roots: &[CertificateDer<'_>],
2172        previous: Option<&UpstreamRegistry>,
2173    ) -> Result<Self, UpstreamError> {
2174        Self::with_root_certificates_previous_and_events(snapshot, extra_roots, previous, None)
2175    }
2176
2177    /// `with_root_certificates_and_previous` with the dataplane's event
2178    /// emitter (DW-044); the single build path behind every constructor.
2179    pub fn with_root_certificates_previous_and_events(
2180        snapshot: &Snapshot,
2181        extra_roots: &[CertificateDer<'_>],
2182        previous: Option<&UpstreamRegistry>,
2183        events: Option<&crate::events::Emitter>,
2184    ) -> Result<Self, UpstreamError> {
2185        let mut default_roots = crate::security::tls::webpki_root_store();
2186        for c in extra_roots {
2187            default_roots
2188                .add(c.clone())
2189                .map_err(|e| UpstreamError::InvalidRootCertificate(e.to_string()))?;
2190        }
2191        // PERF-05 (#170): one shared async DNS cache for every upstream
2192        // in this registry so repeated dials to the same host hit the
2193        // TTL/negative cache. Built once per registry generation; a
2194        // reload builds a fresh cache (acceptable: reloads are
2195        // infrequent, and the cache warms on the first dial to each
2196        // host). Default name servers (the public resolvers) plus the
2197        // system hosts file cover IP-literal and `/etc/hosts` endpoints
2198        // without a network round-trip.
2199        let dns = Arc::new(DnsCache::default());
2200        let handles: BTreeMap<String, Arc<UpstreamHandle>> = snapshot
2201            .gateway()
2202            .upstreams
2203            .iter()
2204            .map(|u| {
2205                let roots = match &u.trusted_ca_file {
2206                    Some(path) => match crate::security::tls::root_store_from_pem_file(path) {
2207                        Ok(roots) => roots,
2208                        Err(err) => {
2209                            // See the method docs: fail closed, never
2210                            // fall back to the public roots (that would
2211                            // silently change who this upstream trusts).
2212                            tracing::error!(
2213                                code = "upstream_ca_unloadable",
2214                                upstream = %u.name,
2215                                path = %path,
2216                                "trusted_ca_file unusable; failing closed (every TLS dial to \
2217                                 this upstream will be refused): {err}"
2218                            );
2219                            rustls::RootCertStore::empty()
2220                        }
2221                    },
2222                    None if u.use_system_roots => {
2223                        // REL-08 (#219): the upstream opted into the
2224                        // OS-native root store instead of the bundled
2225                        // webpki set. Loaded per-upstream (not shared)
2226                        // because the OS store is process-wide and a
2227                        // reload may pick up newly-installed roots.
2228                        crate::security::tls::system_root_store()
2229                    }
2230                    None => default_roots.clone(),
2231                };
2232                let prev = previous.and_then(|p| p.handles.get(&u.name));
2233                (
2234                    u.name.clone(),
2235                    build_handle(u, roots, prev, events, Arc::clone(&dns)),
2236                )
2237            })
2238            .collect();
2239        // DW-040: compile each split service's weighted targets.
2240        // Validation guarantees every named upstream exists and the
2241        // total weight is positive; a handle miss here (impossible
2242        // through validation) skips the split loudly — the service
2243        // answers 502 unknown_upstream rather than dispatching by a
2244        // half-built split.
2245        let mut splits = BTreeMap::new();
2246        for service in &snapshot.gateway().services {
2247            let Some(split_cfg) = &service.split else {
2248                continue;
2249            };
2250            let mut targets = Vec::with_capacity(split_cfg.targets.len());
2251            let mut ok = true;
2252            for t in &split_cfg.targets {
2253                match handles.get(&t.upstream) {
2254                    Some(handle) => targets.push((Arc::clone(handle), u64::from(t.weight))),
2255                    None => {
2256                        tracing::error!(
2257                            code = "service_split_target_missing",
2258                            service = %service.name,
2259                            upstream = %t.upstream,
2260                            "split target has no compiled upstream; split skipped (fail closed)"
2261                        );
2262                        ok = false;
2263                        break;
2264                    }
2265                }
2266            }
2267            if ok {
2268                splits.insert(
2269                    service.name.clone(),
2270                    Arc::new(crate::dataplane::split::ServiceSplit::new(&targets)),
2271                );
2272            }
2273        }
2274        Ok(UpstreamRegistry { handles, splits })
2275    }
2276
2277    /// The compiled split for a service (DW-040), when it has one.
2278    pub fn split_for(&self, service: &str) -> Option<Arc<crate::dataplane::split::ServiceSplit>> {
2279        self.splits.get(service).cloned()
2280    }
2281
2282    /// Handle for the named upstream, or None if the snapshot has no such
2283    /// upstream.
2284    pub fn get(&self, name: &str) -> Option<Arc<UpstreamHandle>> {
2285        self.handles.get(name).cloned()
2286    }
2287
2288    /// Names of all upstreams in this registry.
2289    pub fn names(&self) -> Vec<&str> {
2290        self.handles.keys().map(String::as_str).collect()
2291    }
2292
2293    /// DW-094 (Ent): set the edge's locality context on every upstream's
2294    /// load balancer in this registry. Called by the dataplane at startup
2295    /// and on refresh to propagate the edge's region/zone.
2296    pub fn set_locality_context(&self, ctx: crate::config::LocalityContext) {
2297        for handle in self.handles.values() {
2298            handle.set_locality_context(ctx.clone());
2299        }
2300    }
2301
2302    /// DW-091: rebuild the split for `service` from explicit
2303    /// `(upstream_name, weight)` pairs. Returns a new registry with
2304    /// the replacement split; the handles are shared (Arc bump). The
2305    /// total weight MUST stay constant (the caller enforces this);
2306    /// changing the total would reshuffle every session's branch
2307    /// (the DW-040 invariant).
2308    pub fn with_rebuilt_split_from_pairs(
2309        &self,
2310        service: &str,
2311        pairs: &[(String, u64)],
2312    ) -> Option<UpstreamRegistry> {
2313        let mut new_splits = self.splits.clone();
2314        let mut targets = Vec::with_capacity(pairs.len());
2315        let mut ok = true;
2316        for (name, weight) in pairs {
2317            let Some(handle) = self.handles.get(name) else {
2318                ok = false;
2319                break;
2320            };
2321            targets.push((Arc::clone(handle), *weight));
2322        }
2323        if !ok {
2324            return None;
2325        }
2326        new_splits.insert(
2327            service.to_string(),
2328            Arc::new(crate::dataplane::split::ServiceSplit::new(&targets)),
2329        );
2330        Some(UpstreamRegistry {
2331            handles: self.handles.clone(),
2332            splits: new_splits,
2333        })
2334    }
2335
2336    /// SCALE-10 (#189): pre-warm connection pools for upstreams that
2337    /// configure `pool.pre_warm`. For each upstream with a non-zero
2338    /// `pre_warm` value, spawns a background task that sends lightweight
2339    /// HEAD requests to each endpoint to establish `pre_warm`
2340    /// connections in the pool. This removes cold-start latency spikes
2341    /// after reloads and upgrade hand-offs. Failures are logged and
2342    /// silently ignored (pre-warming is best-effort; a failed
2343    /// pre-warm does not affect the upstream's availability).
2344    pub fn pre_warm(&self) {
2345        for (name, handle) in &self.handles {
2346            let n = handle.pre_warm;
2347            if n == 0 {
2348                continue;
2349            }
2350            let handle = Arc::clone(handle);
2351            let name = name.clone();
2352            tokio::spawn(async move {
2353                let endpoints = handle.lb().len();
2354                if endpoints == 0 {
2355                    return;
2356                }
2357                let per_endpoint = (n as usize).min(64);
2358                tracing::info!(
2359                    code = "upstream_pre_warm_start",
2360                    upstream = %name,
2361                    endpoints,
2362                    per_endpoint,
2363                    "pre-warming {} connections per endpoint for upstream {name}",
2364                    per_endpoint,
2365                );
2366                for _ in 0..per_endpoint {
2367                    for _ in 0..endpoints {
2368                        // Send a lightweight HEAD request to establish
2369                        // a connection. The request targets the
2370                        // upstream's origin with a minimal path; the
2371                        // response is discarded. Failures are silently
2372                        // ignored (best-effort pre-warming).
2373                        let req = http::Request::builder()
2374                            .method("HEAD")
2375                            .uri("/")
2376                            .body(http_body_util::Empty::<bytes::Bytes>::new().boxed())
2377                            .unwrap();
2378                        let _ = handle.send(req).await;
2379                    }
2380                }
2381                tracing::info!(
2382                    code = "upstream_pre_warm_done",
2383                    upstream = %name,
2384                    "pre-warming complete for upstream {name}",
2385                );
2386            });
2387        }
2388    }
2389}
2390
2391/// Refresh the state-derived observation gauges (breaker state, endpoint
2392/// health, fail-open picks) from `registry` into `obs` (DW-021).
2393///
2394/// Called at scrape time: `/metrics` reflects a point-in-time snapshot,
2395/// and the hot paths (pick, report) stay pure atomics with zero metrics
2396/// coupling. The walk lives here — next to the registry state it reads —
2397/// and the observability side only exposes plain setters, so metrics
2398/// depend on nothing. Series for endpoints/upstreams removed by a reload
2399/// linger until process restart (documented Prometheus caveat).
2400pub fn refresh_observation_gauges(registry: &UpstreamRegistry, obs: &Observability) {
2401    for name in registry.names() {
2402        let Some(handle) = registry.get(name) else {
2403            continue;
2404        };
2405        let state = match handle.breaker().state() {
2406            BreakerState::Closed { .. } => 0,
2407            BreakerState::Open { .. } => 1,
2408            BreakerState::HalfOpen { .. } => 2,
2409        };
2410        obs.set_breaker_state(name, state);
2411        obs.set_fail_open_picks(name, handle.lb().fail_open_picks() as i64);
2412        let lb_now = handle.lb().now_ms();
2413        for (address, port, health) in handle.lb().health_targets() {
2414            let label = format!("{address}:{port}");
2415            let up = health.map(|h| h.is_available(lb_now)).unwrap_or(true);
2416            obs.set_endpoint_health(name, &label, up);
2417        }
2418    }
2419}
2420
2421// White-box test staying in src/ per AGENTS.md: exercises private
2422// upstream client internals not reachable through the public API.
2423#[cfg(test)]
2424mod tests {
2425    use super::*;
2426    use crate::config::{LoadBalancer, Upstream as ConfigUpstream};
2427    use bytes::Bytes;
2428    use http_body_util::Full;
2429    use hyper::Method;
2430
2431    fn get_request(path: &str) -> Request<Full<Bytes>> {
2432        Request::builder()
2433            .method(Method::GET)
2434            .uri(path)
2435            .body(Full::new(Bytes::new()))
2436            .expect("request")
2437    }
2438
2439    #[tokio::test]
2440    async fn send_without_endpoints_returns_no_endpoints() {
2441        // Only reachable via unvalidated direct construction; send() is
2442        // the guard, so the handle dials no fabricated address.
2443        let up = ConfigUpstream {
2444            name: "bare".into(),
2445            load_balancer: LoadBalancer::RoundRobin,
2446            hash_on: None,
2447            protocol: UpstreamProtocol::Http1,
2448            endpoints: vec![],
2449            connection_cap: None,
2450            slow_start_ms: None,
2451            health: None,
2452            active_health: None,
2453            retries: None,
2454            timeouts: None,
2455            breaker: None,
2456            max_pending: None,
2457            trusted_ca_file: None,
2458            use_system_roots: false,
2459            oauth2_client_credentials: None,
2460            dns_discovery: None,
2461            peak_ewma: None,
2462            locality: None,
2463            pq: false,
2464            cert_pinning: None,
2465            mtls: None,
2466            pool: None,
2467        };
2468        let handle = build_handle(
2469            &up,
2470            crate::security::tls::webpki_root_store(),
2471            None,
2472            None,
2473            Arc::new(DnsCache::default()),
2474        );
2475        assert!(matches!(
2476            handle.send(get_request("/x")).await,
2477            Err(UpstreamError::NoEndpoints)
2478        ));
2479    }
2480}