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}