Module upstream

Module upstream 

Source
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, 0 disables): 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 hickory DnsCache (PERF-05/#170) with TTL and negative caching, replacing the blocking-pool getaddrinfo of tokio::net::lookup_host; IP-literal and /etc/hosts endpoints skip the network. The active health probes keep the system resolver (a background, infrequent path).
  • Read timeout (per-attempt): timeouts.read_ms wraps 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 one read_ms deadline. A request whose headers do not arrive in time fails with UpstreamError::ReadTimeout (classified 504 by the proxy, and a retryable transport-class failure when retries are enabled). The response BODY is not covered by read_ms.
  • Write timeout (body idle): timeouts.write_ms bounds the response body stream as an INACTIVITY timeout (DW-014): the body wrapper UpstreamBody errors with UpstreamBodyError::WriteTimeout when the gap between two body frames exceeds write_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 is Response<UpstreamBody>, not Response<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 with UpstreamError::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 set max_pending explicitly.
  • TLS: https upstreams negotiate TLS with ALPN http/1.1; http2 upstreams negotiate TLS with ALPN h2 and lock the client to HTTP/2; http1 upstreams 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 via trusted_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’s UpstreamHandle::tls_roots). Programmatic callers can still add extra roots on top of the public set via UpstreamRegistry::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§

UpstreamBody
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).
UpstreamHandle
A send-capable handle to one configured upstream: a dedicated pooled hyper client plus observability. Cheap to share via Arc from the UpstreamRegistry.
UpstreamRegistry
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.
UpstreamStats
Live connection counters for one upstream; observability for pooling behavior (used by tests and later by the admin surface).

Enums§

UpstreamBodyError
Error surfaced by a streamed upstream response body (UpstreamBody, DW-014).
UpstreamError
Error sending a request to an upstream.

Constants§

DEFAULT_CONNECTION_CAP
Default connection cap when connection_cap is absent.
DEFAULT_CONNECT_TIMEOUT_MS
Default connect timeout when timeouts.connect_ms is absent.
DEFAULT_HAPPY_EYEBALLS_MS
Default happy-eyeballs inter-connection delay when timeouts.happy_eyeballs_ms is absent (the RFC 8305 recommended value; DW-030).
DEFAULT_MAX_PENDING
Default pending-request cap when max_pending is 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 getaddrinfo takes); a multi-address resolution races per delay. 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_dial for the pooled connector (PERF-05, #170). Resolution runs through the shared DnsCache (async hickory with TTL + negative caching) instead of the blocking-pool getaddrinfo of tokio::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 using happy_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 registry into obs (DW-021).