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.
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.
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.
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.
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.
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.
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.
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.
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.