Skip to content

Latest commit

 

History

History
389 lines (340 loc) · 23.6 KB

HTTP-Routing.md

File metadata and controls

389 lines (340 loc) · 23.6 KB

HTTP Connection Routing And Reuse

HTTP/1 clients resolve each exchange target into one internal immutable route plan. That plan owns the normalized target and the published Transport snapshot; connection establishment, idle lookup, and return all use its structured compatibility key. A redirect creates a fresh plan. Returning a connection does not reconstruct a key from the current URL or a replacement Transport.

The current connector implements direct HTTP/HTTPS and one explicitly configured HTTP or HTTPS proxy: absolute-form forwarding for HTTP origins and CONNECT followed by origin TLS for HTTPS origins. HTTPS proxy hops independently authenticate the proxy before forwarding or CONNECT.

Malformed target authorities, including an extra unescaped userinfo delimiter, fail with EINVAL at the target stage before any proxy acquisition or CONNECT. Route plans and keys are implementation details, not a public dialing or connection-injection API.

Compatibility Matrix

All rows require the same connector and resolution policy domains, required protocol, and DNS semantics. Equality compares complete values; a hash match is never sufficient. Published domains use unique non-recycled process-local identities, not TLS context addresses, credentials, bearer tokens, or credential hashes. They are not persistent identifiers across process restarts.

Route Target binding Additional compatibility fields Connector status
Direct HTTP Normalized origin host, effective port, HTTP No TLS layer Implemented
Direct HTTPS Normalized origin host, effective port, HTTPS Origin TLS security domain Implemented
HTTP origin through plain HTTP proxy Conservative per-origin binding Ordered proxy endpoint(s), proxy authentication domain, hop protocol/DNS semantics Implemented
HTTP origin through HTTPS proxy Conservative per-origin binding Plain-proxy fields plus outer proxy TLS security domain Implemented
HTTPS origin through plain HTTP CONNECT Permanently target-bound tunnel Ordered proxy endpoint(s), proxy authentication domain, origin TLS security domain Implemented
HTTPS origin through HTTPS CONNECT Permanently target-bound tunnel Plain-CONNECT fields plus independent outer proxy TLS security domain Implemented

Forward channels retain origin authority in their key for now, even though a future connector may deliberately allow sharing across compatible HTTP origins. CONNECT never turns back into a generic forward-proxy channel. Origin authority also remains separate from proxy endpoints for HTTP request encoding/accounting. Local versus proxy target-DNS semantics have distinct identity values; SOCKS and HTTP/2 connector implementations remain outside this change.

Explicit HTTP and HTTPS Proxy

elio::http::transport_config config;
config.proxy = elio::http::http_proxy_config{};
config.proxy->endpoint = "http://proxy.example:3128";
// Optional, explicitly selected credentials from the caller's secret store:
config.proxy->basic_auth = elio::http::proxy_basic_credentials{"user", "password"};
config.limits = elio::http::pool_limits{};
config.acquisition_timeout = std::chrono::seconds(10);
auto owner = std::make_shared<elio::http::transport>(config);
elio::http::client client(owner);
auto result = co_await client.get_result("https://origin.example/path");

An absent proxy preserves direct routing. The endpoint must be an explicit http://host[:port] or https://host[:port] authority, optionally ending in /. The schemes select plaintext/TLS, not port-number guessing. URI userinfo, other paths, query, fragment, unsupported schemes and unbounded configuration are rejected during construction. There is no environment-variable discovery, PAC, SOCKS, chain of proxies, HTTP/2 proxy mode or automatic authentication replay. Mutating the original config or client.config().proxy later does not change a published Transport; construct a new owner to change the hop or credentials. Standalone connection_pool adapters remain direct-only. Constructing one with a nonempty proxy option throws std::invalid_argument instead of silently bypassing the selected proxy.

Only the proxy endpoint is resolved locally. The proxy resolves the target name, whether forwarding an absolute URI or opening a CONNECT target. Origin TLS still verifies the origin hostname with its own frozen trust policy and SNI, not the proxy address. configure_tls continues to configure origin TLS; nonempty negotiated ALPN other than http/1.1 is unsupported on these routes.

Forwarding uses an absolute URI and its corresponding origin Host, never URI userinfo or fragment. Custom forwarded requests must supply an origin-form path/query (or OPTIONS *); alternate authorities and raw fragment components are rejected. Server-wide OPTIONS * preserves asterisk form, the client-side exception to absolute-form forwarding. CONNECT uses authority form with the effective port, including IPv6 brackets, followed by ordinary origin-form HTTP inside TLS. See RFC 9112 request-target forms.

The route connector owns that CONNECT negotiation. Caller-supplied CONNECT requests through an explicitly proxied client are unsupported: value APIs return ENOTSUP at the request stage before transport acquisition, and optional adapters return empty. These high-level APIs return HTTP responses, not raw tunnel handoffs. Direct-route caller CONNECT handling and public request serialization are unchanged; the credential-header migration below still applies.

Proxy Basic credentials are bounded caller-selected octets, not implicitly charset/locale converted. Controls and a colon in the username are rejected; a password may contain a colon. They are encoded using RFC 7617 Basic, and are sent preemptively only to this explicitly selected hop. A plain HTTP proxy does not encrypt those credentials or HTTP-origin traffic. Generic caller Proxy-Authorization headers cannot select a credential domain: the client strips them from direct/tunneled requests and replaces them with the frozen hop credential on forwarding. Origin Authorization remains subject to existing origin/redirect policy. Public request::serialize() itself is unchanged.

CONNECT uses the existing response decoder/reader with finite policy bounds:

proxy_connect_limits Default Meaning
max_headers 100 Fields per response head
max_header_size 8192 Bytes per header field line
max_response_bytes 65536 All received setup bytes, including interim heads and read-ahead
max_informational_responses 8 Interim responses before the final head
max_read_ahead 8192 Owned prefix passed to the tunneled TLS reader

Zero is a literal bound: zero interim capacity forbids interim responses; zero read-ahead uses single-byte setup reads rather than discarding tunnel input. Any successful 2xx switches to the tunnel immediately after the head and ignores Content-Length/Transfer-Encoding framing, as required by RFC 9110 CONNECT. Read-ahead is owned and delivered in order exactly once, before lower input or EOF. A CONNECT 407 is EACCES, other rejected final statuses are ECONNREFUSED, 101 is ENOTSUP, and malformed/over-limit setup has an owned error at proxy_connect. No rejected channel is pooled or automatically replayed. A forwarding 407 remains an ordinary HTTP response.

After local proxy DNS, one connect_timeout budget covers proxy TCP, proxy TLS when selected, CONNECT and origin TLS without restarting at any transition. When configured, the same absolute acquisition_timeout also covers queueing and proxy DNS. Request/body I/O keeps its existing response deadline. Layered I/O cancellation/timing uses the owned protocol chain rather than bypassing it through a root descriptor.

HTTPS Proxy Security And Composition

config.proxy->endpoint = "https://proxy.example:8443";
config.proxy->verify_certificate = true; // independent default: true
config.proxy->configure_tls = [](elio::http::transport_tls_config& policy) {
    if (!policy.load_verify_locations("proxy-ca.pem"))
        throw std::runtime_error("Proxy trust initialization failed");
};
config.configure_tls = [](elio::http::transport_tls_config& policy) {
    if (!policy.load_verify_locations("origin-ca.pem"))
        throw std::runtime_error("Origin trust initialization failed");
};

The existing Transport verification flag and configure_tls remain origin-only. The proxy callback uses the same construction-only builder, but publishes a separate TLS context. Trust stores, client certificates/keys, reference names, SNI, ALPN and session state are not shared between layers. A trusted proxy does not authenticate the origin. Numeric proxy and origin references require IP SANs and send no SNI; DNS references use their independently decoded names. Disable verification only in the intended domain; neither flag changes the other. Proxy TLS callbacks are not invoked for plain HTTP proxy endpoints.

The proxy layer advertises http/1.1 by default. Its builder accepts only that protocol or an empty ALPN list; other lists return false without changing the previous policy. Outer HTTP/2 CONNECT is not implemented. Nonempty selected ALPN other than HTTP/1.1 is rejected in the corresponding TLS stage. Failures at outer handshake are proxy_tls; CONNECT errors remain proxy_connect, and origin handshake errors remain tls. Existing stage numbers are unchanged.

HTTP forwarding uses HTTP -> TLS(proxy) -> owned TCP root. HTTPS uses HTTP -> TLS(origin) -> owned CONNECT prefix -> TLS(proxy) -> owned TCP root. Both TLS layers use the same generic implementation and exclusively drive their next layer. Forwarded HTTP watchdogs still use the outer proxy TLS stream's exposed descriptor. Only tunneled HTTPS uses descriptor-free route-operation watchdogs for inner HTTP I/O; nested TLS and prefix I/O never bypass the owned chain through the root descriptor. Buffered outer plaintext is available without waiting for new TCP readiness.

The two custom output BIOs each retain at most the default 1 MiB ciphertext payload, so their aggregate retained output payload is at most 2 MiB, plus the configured CONNECT prefix (default 8 KiB). This is not a total-memory limit: borrowed application payload, TLS/OpenSSL/control allocations, parser storage and kernel socket buffers are separate. Backpressure and submitted-storage ownership apply recursively until each lower operation completes.

Inner finish_write is layer-local: TLS 1.3 closes its write direction and TLS 1.2 uses its whole-session rules without pretending to close the outer TLS protocol. Whole-stack abort seals and settles the owned chain. EOF remains a TLS result at the layer where it occurs; raw EOF is not an authenticated alert. An inner failure or finish never recovers a pre-CONNECT outer HTTP connection for forwarding or another target. HTTP Transport retires closed sessions.

Idle reuse requires quiescence of every TLS and prefix layer: no retained lower frame, active/pending output, sealed channel or unread CONNECT prefix. Root accounting is installed before outer handshake and remains alive through failed setup, whole-chain abort and inactive-but-retained output frames.

Normalization And Policy Ownership

ASCII DNS names are case-folded. Parsed IPv4 and IPv6 literals are canonicalized; IPv6 authorities retain brackets and effective ports. General route identity keeps IPv6 zone identifiers case-sensitive. Explicit proxy URI endpoints and targets accept RFC 3986 reg-names (including well-formed percent escapes) or IPv4/IPv6 literals. Malformed escapes and backslashes are rejected before dialing. URI-string entry points and redirects additionally reject bracketed non-IP authorities. APIs taking url validate supplied components and cannot recover bracket provenance discarded by url::parse. For the locally resolved proxy endpoint, escapes must encode ASCII unreserved octets; they are decoded once before both hop-key publication and DNS. Encoded delimiters, nested escapes and non-ASCII octets are rejected during construction; use an IDNA ASCII label for an internationalized proxy name. Target URI escapes remain unchanged in CONNECT and HTTP Host/request targets on the proxy wire. HTTPS origins use a separate TLS reference name: ASCII unreserved escapes are decoded once and the name is case-folded before certificate verification/SNI. Unsupported encoded octets and literal or encoded leading-dot reference names are rejected before acquisition; use an IDNA ASCII label for internationalized origins. This does not change the generic direct TLS naming policy. IPvFuture and scoped IPv6 zone syntax are not supported by the explicit proxy connector: it does not reinterpret or forward node-local scope identifiers. Direct route normalization is unchanged. Trailing-dot DNS names and alternate/ambiguous IPv4 spellings are conservatively distinct. Implicit and explicit default ports compare equal. Paths, queries, fragments, URI userinfo, and application headers are not connection identity fields. No secret URL components are retained in the route plan. HTTP/1 request/redirect diagnostics log destination host/port or a bounded rejection category, not serialized headers/bodies or complete Location URLs.

A plan strongly retains its published DNS admission and origin TLS owners. Configured resolver caches retain their existing borrowed-cache lifetime contract. The connector consumes the same normalized endpoint and frozen resolver/security configuration from which the key was derived. New transports receive new policy domains even when their option values happen to match; explicitly sharing one Transport is how clients share its compatible connections. Publishing another Transport during a suspended acquisition does not change the old plan, the old connection's return key, or the old pool owner.

Read/Expect/body/redirect/User-Agent policy and per-acquisition connect deadlines remain request policy. They do not split connection identity. A route plan by itself does not prove that a partially read response is reusable. Transport admission and acquisition budgets are configured separately below. The exchange still requires complete framing, keep-alive, no EOF/close-delimited body, and no unread suffix before returning a connection.

Internal Connection Leases

Buffered requests and scoped with_response exchanges carry a move-only internal lease. It owns the original route plan and a strong reference to the original Transport state from dialing until disposition. Moving a lease transfers its single disposition; moved-from destruction does nothing.

Only the exchange owner can return it after complete, unambiguous HTTP/1 framing, keep-alive eligibility, no EOF/close-delimited body, no read-ahead suffix, and settlement of its I/O/watchdogs. Every other path retires by aborting and disconnecting the stream. Destruction never marks an unfinished response reusable, drains a body, or starts hidden asynchronous TLS shutdown. Allocation exceptions during pool insertion still leave the lease responsible for retirement. There is no public unchecked reuse flag or escaping lease API. Direct HTTPS and CONNECT tunnels are conservatively retired when TLS output or an owned lower frame remains after HTTP completion; drained request bytes alone do not prove quiescence. Terminal settlement retains the operation owner and physical permit until that work releases the root. The direct HTTPS root is bound to that accounting before handshake. A failed TLS attempt releases its physical root before another resolved address can use the same reserved dial slot; pump inactivity alone is not frame destruction. These exclusively library-created TLS/channel TCP roots disable socket linger before TLS/channel construction and close deterministically after their owned lower frames exit, including on io_uring. Queuing an asynchronous close is not the settlement boundary. This private policy does not change ordinary public TCP destruction or permit callers to destroy a frame with borrowed I/O still pending.

transport::clear() atomically detaches idle entries with the pool-generation change; detached streams are destroyed outside lifecycle/pool locks. An already acquired or dialing lease may finish its current exchange, but its late return is retired. New work may acquire normally. shutdown() additionally closes acquisition and awaits settlement; cancelling that wait does not cancel or destroy active public frames. A returned CONNECT identity remains bound to its target; retiring it never recovers the underlying proxy channel for a new tunnel.

Internal state retention does not relax caller lifetimes: keep the client, scheduler, borrowed request/URL/string inputs, and configured resolver cache alive until normal awaited return. Scoped streaming readers and their pending reads must not escape the handler.

Opt-In Finite Admission

Default Transport admission remains unbounded for compatibility. max_connections_per_host still caps retained idle connections per route; it is not a concurrent/live-connection limit. To opt in, set transport_config::limits to pool_limits{} and adjust the distinct resources:

elio::http::transport_config config;
config.limits = elio::http::pool_limits{};
config.limits->max_live_total = 128;
config.acquisition_timeout = std::chrono::seconds(10);
auto owner = std::make_shared<elio::http::transport>(config);
elio::http::client client(owner);
Limit Finite preset Meaning
max_idle_per_route 6 Retained idle streams in one compatibility route
max_idle_total 64 Retained idle streams in the whole Transport
max_live_per_route 12 Idle, leased, retiring, and reserved streams in one route
max_live_total 128 The same live states across the whole Transport
max_dials_total 16 Reserved/in-progress DNS, TCP and TLS establishments
max_waiters_total 256 Queued acquisitions, not already granted handoffs
max_route_buckets 128 Route accounting/idle-cache metadata buckets

Every zero limit denies that resource rather than meaning unlimited. Zero idle limits disable retention without disabling new requests. Zero waiter capacity means fail-fast when immediate admission is unavailable. The finite idle limits replace the legacy idle setting when limits is present. The convenience client_config constructor copies these options into its private Transport; clients sharing an explicit Transport use that owner's immutable configuration.

Capacity is reserved before DNS/dialing. Successful establishment releases its dial slot but retains live capacity through the whole exchange, including a scoped streaming handler and its unread body. Moving a lease or permit transfers its capacity ownership to the destination; it does not make capacity available. Failure, cancellation, timeout, destruction and late-return retirement release the permit once. Plain TCP roots created privately by the Transport close their local descriptor before releasing retirement capacity, including setup exceptions, active discard, idle clearing and shutdown. This boundary is local descriptor closure, not peer acknowledgement or TCP protocol-state removal; queueing an asynchronous close is not sufficient. These exclusive roots disable positive SO_LINGER, so retirement does not wait for peer delivery on a worker. Ordinary standalone TCP stream/connector teardown is unchanged. For an owned tunnel whose internal output/lower frame still holds the root, retirement retains its permit until physical root closure; it does not immediately advertise a spare connection. TLS-owned frame operation tracking is extended separately in #1272. Empty metadata buckets are removed; idle-only buckets/streams may be evicted to admit a different route within the metadata/global-live caps. Active or queued buckets are not evicted.

Waiters use global FIFO: a saturated head route can delay later routes, even if those routes have capacity. New acquisitions do not bypass queued waiters. Queueing does not block a scheduler worker and is cancellable. A full waiter or metadata budget, or a resource denied by a zero limit, returns EAGAIN at stage acquire. A pending wait without a running scheduler is unsupported. Shutdown wakes queued acquisitions with ESHUTDOWN, stops new admission, and waits for already reserved/leased exchanges. Cancellation returns ECANCELED; a selected handoff can beat cancellation, but the client checks cancellation before dialing or using its stream. Cancelling a shutdown observer does not cancel exchanges.

Per-route accounting uses the complete compatibility key, including origin, proxy/security/authentication domains and DNS semantics. It is not an aggregate per-origin or per-proxy-endpoint quota across different routes. This version does not advertise a separate endpoint quota or cross-origin forward-channel sharing. Global live/dial limits bound pressure across all routes in one shared Transport; independent Transports have independent budgets.

Absolute Acquisition Budget

acquisition_timeout is disabled by default (<= 0). When enabled, it creates one absolute steady-clock deadline on acquisition entry, before queueing. The same deadline bounds the queue, DNS observer, TCP retries, CONNECT and TLS handshake; stage transitions and retries never restart it. It applies independently of whether finite limits are enabled. Timeout errors retain the active stage: acquire for queue expiry, resolve for DNS, and connect/proxy_tls/proxy_connect/tls for setup.

The independent dns_timeout cap may shorten the DNS portion. client_config::connect_timeout starts after DNS and is intersected with the remaining absolute acquisition budget. Direct routes cover TCP plus TLS; proxy routes include CONNECT in the same remaining setup budget. Read/write/Expect clocks retain their existing request/response semantics; this is not a total-response or handler-execution deadline. DNS observer departure does not destroy running libc work: the separate DNS admission domain retains that work's capacity until actual completion.

Finite admission can introduce queueing that legacy callers never experienced. In particular, do not await a nested request on a fully saturated Transport while holding every lease needed for its progress; use adequate capacity or a separate Transport. Use a finite acquisition timeout when indefinite FIFO waiting is unacceptable. Operational overload/deadline errors use the existing client_result/client_error API; no additional error taxonomy is introduced.

Standalone Pool Compatibility

Legacy connection_pool::{acquire_result,acquire,release} signatures remain available and use a separate legacy identity namespace. They cannot inject a connection into a Transport's plan-based pool. Their loose acquire/release contract still requires callers to supply the original host/port/scheme and keep one stable TLS/resolver policy per standalone pool; they cannot infer a security domain from a borrowed mutable TLS context. To change its TLS or DNS policy, first settle all old acquisitions/exchanges and dispose of all checked-out connections, then clear the idle entries. clear() alone is not a barrier against late returns. Alternatively, route new work through a fresh pool and keep the old pool/context alive and unchanged for old operations/returns. For automatic route/security isolation and client-managed lifecycle, migrate to client(shared_ptr<transport>, policy). These legacy adapters do not gain the Transport's lease/generation guarantees. They also do not acquire finite live/dial/waiter admission; finite limits and the queue-inclusive acquisition budget are Transport-managed options, not a reinterpretation of standalone acquire/release.