Expand description
Pooled upstream clients (DW-008, feature analysis 4.1).
One hyper-util legacy client (a connection pool) per configured
upstream, keyed by upstream name inside an UpstreamRegistry. Each
pool’s connector is tuned per upstream:
- Connect timeout:
timeouts.connect_ms(default 5 s) wraps the whole dial: resolution plus TCP connect plus, for TLS upstreams, the TLS handshake. - Happy-eyeballs dial (DW-030, RFC 8305): an endpoint address
that resolves to multiple addresses — the dual-stack hostname
case — is dialed with interleaved address-family attempts
(
timeouts.happy_eyeballs_ms, default 250 ms,0disables): the resolver’s first address defines the preferred family (RFC 8305 leaves the preference to the resolver’s order — the system’s RFC 6724 sort), the first attempt starts immediately, each subsequent attempt starts one delay after the previous START (or immediately when an attempt fails with nothing else in flight), and the first success wins, cancelling the losers. Exactly ONE outcome — the overall dial’s — reaches the breaker and passive-health accounting: the losing arms of one dial are dial-internal retries, never endpoint failures. The dial (and the [happy_race] primitive beneath it) is ours, not hyper-util’s implicit default: the RFC 8305 shape is now documented, configurable, and observable in tests. DNS resolution on the pooled dial path runs through the shared async hickoryDnsCache(PERF-05/#170) with TTL and negative caching, replacing the blocking-poolgetaddrinfooftokio::net::lookup_host; IP-literal and/etc/hostsendpoints skip the network. The active health probes keep the system resolver (a background, infrequent path). - Read timeout (per-attempt):
timeouts.read_mswraps each pooled request/response-header exchange (DW-014) — from the moment the request is handed to the pool (including any connection-cap queue wait and the dial) until the response HEADERS resolve. It is therefore also the per-attempt total bound: pool wait + connect + write request + read headers, all inside oneread_msdeadline. A request whose headers do not arrive in time fails withUpstreamError::ReadTimeout(classified 504 by the proxy, and a retryable transport-class failure when retries are enabled). The response BODY is not covered byread_ms. - Write timeout (body idle):
timeouts.write_msbounds the response body stream as an INACTIVITY timeout (DW-014): the body wrapperUpstreamBodyerrors withUpstreamBodyError::WriteTimeoutwhen the gap between two body frames exceedswrite_ms. It is not a total streaming budget — a body that keeps trickling frames never trips it. The wrapper also reports a mid-stream abort (transport error or idle timeout after headers resolved) as a passive-health FAILURE for the picked endpoint, closing the DW-012 gap where mid-body deaths were invisible to ejection. Requires a body wrapper: the handle’s response type isResponse<UpstreamBody>, notResponse<Incoming>(documented; the wrapper is a thin frame passthrough when both knobs are unset). - Connection cap:
connection_cap(default 256) bounds concurrent outbound connections to the upstream — active AND pooled-idle — via a semaphore permit acquired before dialing and stored inside the connection’s IO wrapper, so it is released exactly when the connection closes (permit drop). Excess connection attempts queue on the semaphore; they are never failed, only delayed. Because the permit wait has no deadline of its own, requests can queue indefinitely while the cap is saturated; request-level timeouts (DW-014) will bound that wait. The effective cap is clamped to a minimum of 1: validated configs always pass a cap >= 1, but direct construction with cap == 0 would build a zero-permit semaphore whose dials hang silently — misuse degrades to serial connects instead. Documented choice: the cap counts connections, not in-flight requests — HTTP/1.1 multiplexes several requests over one connection only sequentially, and h2 pools share one connection per origin anyway. - Pending cap (DW-015):
max_pending(default 256) bounds how many requests may WAIT for a connection-cap slot. Over that, the connector fails fast withUpstreamError::Saturated(classified 503 “upstream saturated” by the proxy) instead of queueing; a pending slot is held from the dial attempt until the connection-cap permit is acquired, then released (the request is connecting, no longer pending). The default bounds memory under high concurrency (#270); operators who need deeper queueing can setmax_pendingexplicitly. - TLS:
httpsupstreams negotiate TLS with ALPNhttp/1.1;http2upstreams negotiate TLS with ALPNh2and lock the client to HTTP/2;http1upstreams dial plaintext. Server certificates are verified against the Mozilla webpki root set by default (chosen over system roots for determinism in tests; system roots are a follow-up). Private-CA upstreams are configured per upstream viatrusted_ca_file(#121): the PEM bundle REPLACES the public roots for that upstream, and its active https health probes verify against the same roots (see the handle’sUpstreamHandle::tls_roots). Programmatic callers can still add extra roots on top of the public set viaUpstreamRegistry::with_root_certificates.
Load balancing (DW-011): every dispatch picks its endpoint through the
upstream’s crate::dataplane::balance::UpstreamLb (smooth weighted round-robin,
least-connections, random-2, or ketama ip-hash; slow-start ramps; hot
endpoint-set swaps that carry per-address state). Config lifecycle:
build a registry from a published snapshot; reloads rebuild it from the
new snapshot while carrying balancer state (see
UpstreamRegistry::from_snapshot_with_previous), mirroring how
TlsTermination is reloaded.
Retries (DW-014) are driven from the proxy (which owns the request
body replay decision); the handle exposes the resolved
crate::resilience::retries::RetryParams and the per-upstream
crate::resilience::retries::RetryBudget for that loop, and every send call is
one ATTEMPT (fresh load-balancer pick, fresh per-attempt deadlines).
Structs§
- Upstream
Body - The error is terminal for the stream: frames already forwarded to the client end abruptly (HTTP/1.1 truncation semantics); it is never retried (an attempt is final once its headers resolved).
- Upstream
Handle - A send-capable handle to one configured upstream: a dedicated pooled
hyper client plus observability. Cheap to share via
Arcfrom theUpstreamRegistry. - Upstream
Registry - Registry of per-upstream pooled clients, built from one published snapshot. Rebuild (and drop the old registry) on snapshot swap; in-flight requests keep their old pools until their handles are dropped.
- Upstream
Stats - Live connection counters for one upstream; observability for pooling behavior (used by tests and later by the admin surface).
Enums§
- Upstream
Body Error - Error surfaced by a streamed upstream response body
(
UpstreamBody, DW-014). - Upstream
Error - Error sending a request to an upstream.
Constants§
- DEFAULT_
CONNECTION_ CAP - Default connection cap when
connection_capis absent. - DEFAULT_
CONNECT_ TIMEOUT_ MS - Default connect timeout when
timeouts.connect_msis absent. - DEFAULT_
HAPPY_ EYEBALLS_ MS - Default happy-eyeballs inter-connection delay when
timeouts.happy_eyeballs_msis absent (the RFC 8305 recommended value; DW-030). - DEFAULT_
MAX_ PENDING - Default pending-request cap when
max_pendingis absent (#270).
Functions§
- effective_
cap - Public for testing the connection-cap clamping contract.
- happy_
dial - Resolve one endpoint authority and dial it with the RFC 8305 racing
(DW-030). IP-literal authorities skip DNS entirely (the resolver’s
short-circuit, the same path
getaddrinfotakes); a multi-address resolution races perdelay. One dial, one outcome: the caller’s health/breaker accounting sees exactly this Result, never the losing arms’ failures. Shared by the pooled connector and the active health probes (one dialing discipline per upstream). - happy_
dial_ via - The cached variant of
happy_dialfor the pooled connector (PERF-05, #170). Resolution runs through the sharedDnsCache(async hickory with TTL + negative caching) instead of the blocking-poolgetaddrinfooftokio::net::lookup_host; the RFC 8305 racing and NODELAY behavior are identical. IP-literal hosts skip DNS via the cache’s short-circuit. The active health probes keep usinghappy_dial(system resolver) — they are a background, infrequent path and do not share the pooled connector’s cache. - refresh_
observation_ gauges - Refresh the state-derived observation gauges (breaker state, endpoint
health, fail-open picks) from
registryintoobs(DW-021).