NAME
Unblock::HTTP::Client - Reusable HTTP client with framework transport factories
SYNOPSIS
my $client = Unblock::HTTP::Client->new(
http1 => {}, http2 => {}, http3 => {},
connect_stream => sub {
my ($origin, $alpn_choices, $ready) = @_;
# Framework opens TCP/TLS and completes asynchronously:
# $ready->(transport => $stream, alpn => $selected_alpn);
},
connect_quic => sub {
my ($origin, $ready) = @_;
# Framework establishes QUIC and completes asynchronously:
# $ready->(connection => $ready_quic);
},
defer => sub {
my ($callback) = @_;
# Framework schedules callback for a later event-loop turn.
},
schedule_timer => sub {
my ($seconds, $callback) = @_;
# Framework schedules a one-shot timer, then returns a coderef
# that cancels it when the request finishes.
},
);
# Optional idle_timeout => 30 requires schedule_timer and an adapter
# should supply release_connection to close its owned transport.
my $operation = $client->request(
method => 'GET', url => 'https://example.com/',
timeout => 15, # Optional, seconds; requires schedule_timer.
on_response => sub {
my ($tx, $response) = @_;
print $response->status, "\n";
},
);
DESCRIPTION
One definition handles requests to many origins. It owns HTTP-aware scheduling and protocol engine objects, not sockets, TLS or timers. The attached framework owns transports and calls the real engine's input(), input_eof(), transport_error() and resume_output() methods. Factories must retain and feed those returned engines.
request() returns an operation handle immediately. Its transaction() returns the actual engine transaction after dispatch. No transaction wrapping, method injection or duplicate receive accumulator is used.
FACTORIES
connect_stream($origin, $alpn_choices, $ready) is for TCP/TLS. Each factory invocation MUST establish a distinct transport, or return an existing idle transport whose ownership is explicitly transferred. Never attach multiple different engines to the same framework stream. The origin is a hash with scheme, host, port and authority; ALPN choices are wire names h2 and/or http/1.1. Call $ready->(transport => $host, alpn => $negotiated) exactly once after connection establishment. An empty negotiated ALPN means HTTP/1.1, which must be enabled. Transport must provide unblock_send/unblock_finish/unblock_abort. For verified HTTPS TLS connections, supply verified_origin => 1 only after the framework has validated the peer certificate and matched it to the original request hostname. Without that explicit assurance, received Alt-Svc headers are ignored.
connect_quic($origin, $ready) provides a ready, open QUIC connection and calls $ready->(connection => $quic) exactly once. The Client starts the H3 engine; the framework handles Net::QUIC::Driver UDP and dynamically scheduled timers. Either factory can report $ready->(error => $reason).
When Alt-Svc has advertised HTTP/3, the connect_quic origin hash additionally contains alternative_host and alternative_port. The ordinary host, port, authority and request headers still describe the ORIGINAL origin. The QUIC factory must authenticate its peer against that original origin, not merely the alternative endpoint, and return verified_origin => 1 after that verification. Otherwise the alternative is rejected and the unsent request may fall back to TCP.
defer($callback) MUST arrange a later event-loop turn, not call inline. This prevents queued requests from reentering an HTTP/1 engine before its previous on_finish callback has unwound.
schedule_timer($seconds, $callback) is an optional framework factory used when a request has a positive timeout or when the Client is configured with idle_timeout. It must schedule a one-shot callback for a future event-loop turn and return a coderef that cancels that timer. Timer cancellation is invoked once at terminal cleanup. No event loop, timers or blocking waits are created inside the core. The timeout covers the ENTIRE request lifetime, including pending queue time, connection setup and response completion. With no timeout, no Client operation timer is created.
idle_timeout => $seconds is an optional positive idle connection lifetime. It requires schedule_timer and defaults to disabled. A connection's timer is armed after its last transaction finishes and no operations are queued for that origin; it is cancelled upon reuse and rearmed after the next quiet period. Queued requests waiting for H2 peer SETTINGS or H3 QUIC stream credit prevent idle expiration. Expiration never cancels an active transaction.
release_connection($host, $engine, $protocol, $reason) is an optional framework hook for reclaiming the socket, QUIC resources, and attached engine exactly once. The reason is idle_timeout, evicted, closed, draining, or network_changed, or shutdown. If the callback is omitted, the Client calls the engine's close() when supported, otherwise the connection's close(). Framework adapters that own transport lifecycle should provide the hook to clean up their actual socket/QUIC objects. Idle timeouts use one cancellable timer per idle connection; there is no global polling loop.
REQUESTS AND SCHEDULING
request(method => 'GET', url => $url, ...) constructs a Uniform::HTTP::Request with canonical scheme, authority and origin-form target. HTTP and HTTPS URLs are accepted; userinfo and fragments are not. http_version => '1.1', '2' or '3' is strict and cannot silently downgrade. An ordinary cold request prefers TCP/TLS H2/H1 negotiation; H3 without discovery is only chosen explicitly or when it is the sole enabled protocol. Authenticated same-host Alt-Svc h3 records can select a later request's QUIC transport. clear, freshness lifetime, Age and invalidation after failed connections are honored in a bounded in-memory cache. Cross-host alternatives and DNS HTTPS discovery are not supported. No connection-establishment racing occurs.
Defaults: max_pending_requests = 128, max_connections_per_origin = 4, max_streams_per_connection = 32. Idle expiration is disabled unless idle_timeout is explicitly set. Every limit must be positive. The request queue rejects overflow. HTTP/1 handles one request at a time on each connection. HTTP/2 admission also honors the real engine's can_open_transaction(), including the peer's advertised concurrent stream limit and GOAWAY/draining state. If the peer advertises zero streams, the request remains queued until a later SETTINGS increase; the Client does not repeatedly discard a healthy connection. HTTP/3 uses the bounded per-connection cap. If QUIC cannot open a bidirectional stream, the engine returns no transaction and emits a later one-shot on_request_available notification when stream availability changes. The Client preserves the SAME unsent operation in its bounded queue, blocks that H3 connection until the engine notification, then uses framework defer before trying admission again. It does not poll QUIC or manufacture a Transaction. Once an engine returns a real Transaction, no automatic replay is permitted. Draining/GOAWAY connections are retired once idle.
Connections are reused by origin and the requested protocol policy.
shutdown() permanently stops new requests, cancels queued and connecting operations (including their framework timers and cancellable factories), and releases idle transports with reason shutdown. Accepted transactions are not cancelled or replayed: active HTTP1/2/3 connections retire after their last transaction finishes. The method is idempotent, returns the Client, and makes no new connections. Pending cancellations invoke on_finish(operation) but not on_error. Late factory readiness cannot reattach. is_shutdown() reports whether admission has stopped. Applications may explicitly cancel active handles when immediate termination is needed. Network changes after shutdown do nothing.
network_changed() is an explicit notification from the framework when the underlying network identity has changed (for example, a VPN switch or network interface handoff). The Client does NOT detect network events itself. The method flushes all cached Alt-Svc hints and discards unused connections immediately via release_connection with reason network_changed. A connection with active transactions is marked retiring; its existing requests are neither cancelled nor replayed, and it is released only after those transactions finish. Pending requests retain their operation handles, deadlines, queue positions and callbacks. Any old in-progress connection factory is abandoned, using its optional cancellation hook; late readiness is ignored. Advertisements received on a connection predating the network change cannot repopulate Alt-Svc. The next scheduler turn establishes fresh transports as capacity permits.
pool_stats() returns a copy of the current scheduler counts with origins, connections, active, idle, retiring, credit_blocked, connecting, pending, and by_origin. by_origin is a list of snapshot hashes with scheme, host, port and the same counts for each origin (other than origins). These are observational counts only. No engine, socket, QUIC object or mutable scheduler structure is returned. It does not run callbacks, attach transports or create timers.
An operation can be cancelled while still queued/connecting, including while an H3 connection is waiting for peer bidirectional-stream credit. A connect factory may return a cancellation coderef to abort establishment if the initiating operation is cancelled or times out. Later readiness from an abandoned attempt is ignored, never attached to an HTTP engine, and the factory is responsible for disposing of any unused transport it created.
With no on_data, the engine buffers the complete response within the configured per-protocol max_buffered_body_bytes (1 MiB by default); on_response runs once on successful body completion. With on_data, streaming is selected automatically; on_response runs at final headers, on_data($tx, $chunk) receives body bytes, and on_end($tx) runs after the response body. on_finish runs after terminal state. Engine-issued request callbacks receive actual engine Transaction objects. Factory failures and pre-dispatch cancellations cannot have a Transaction: on_error/on_finish receive the operation handle instead. Cancellation is not an error. An unsuccessful alternative connection can fall back to TCP BEFORE the request reaches an HTTP engine, including a POST, because no request has been transmitted. The Client never automatically replays a dispatched transaction, even a GET: transport errors cannot establish whether a peer processed it. A strict HTTP version cannot fall back.
A timeout => $seconds requires schedule_timer on the Client. Expiry before dispatch reports on_error(operation, "request timed out ...") followed by on_finish(operation); no fake engine Transaction is created. Expiry after dispatch cancels the real engine transaction using its native cancel() method, reports on_error($tx, $timeout_error) before on_finish($tx), and sets the operation state to error. The engine transaction itself records a cancellation because no protocol parser error has been invented. An intentional user cancel() remains distinct: it does not manufacture an on_error callback. No timed-out request is automatically replayed.
HTTP/1.1 WEBSOCKETS
connect_websocket() is an opt-in HTTP/1.1 client upgrade, available only with http1 => { websocket => 1 }. It uses the same one-object Client factory and per-origin admission queue as ordinary HTTP requests:
my $client = Unblock::HTTP::Client->new(
http1 => { websocket => 1 },
connect_stream => sub { ... },
defer => sub { ... },
);
my $operation = $client->connect_websocket(
url => 'wss://example.com/chat',
on_open => sub {
my ($ws, $http_tx) = @_;
$ws->send_text('hello');
},
on_message => sub {
my ($ws, $message, $type) = @_;
},
on_error => sub {
my ($object, $error) = @_;
},
);
The optional Unblock::WebSocket library builds and validates the HTTP/1.1 GET Upgrade request. on_open($ws, $http_tx) runs only after the HTTP 101 response has passed validation and the framework stream has been assigned to the WebSocket. The Client operation is the handshake operation, not a wrapper around the established WebSocket. Its transaction() returns the real H1 transaction, and websocket() returns the real Unblock::WebSocket::Client after successful Upgrade. on_finish observes the HTTP handshake terminal state; it does not wait for the WebSocket Close handshake.
The method accepts url with ws or wss scheme, optional headers, origin, subprotocols and permessage_deflate for handshake negotiation, WebSocket constructor options backend, max_message_size, high_water, low_water and random_bytes, and handlers on_open, on_message, on_ping, on_pong, on_close, on_drain, on_error, on_finish and optional handshake timeout. on_error may be called with the operation or HTTP transaction for a handshake failure, and with the WebSocket engine for a later WebSocket protocol error. on_open is optional, but is needed to react immediately to asynchronous establishment.
A TCP/TLS host used for a WebSocket must additionally implement unblock_upgrade($ws) and synchronously select that WebSocket as its new input, EOF, error and resume-output consumer. It must retain the same ordered socket output queue: the HTTP request and frames use that queue. The Client transfers any HTTP1 take_remainder() into the WebSocket after the transition. For a native borrowed-input provider, INPUT_SWITCH leaves the unconsumed window with the native transport; it must feed that suffix into the new consumer exactly once.
After the successful switch, the connection is removed from the HTTP pool without calling release_connection() or closing the transport. The framework and WebSocket own the connection lifetime from then on; shutdown() does not forcibly close an already-established WebSocket. Other HTTP work for the same origin opens/reuses a different HTTP stream. Rejected or malformed handshakes fail the original operation; there is no automatic request replay. Calling cancel() before establishment uses the usual queued/connecting/active operation cancellation semantics.
HTTP/2 WEBSOCKET EXTENDED CONNECT
Opt in with http2 => { websocket => 1 } and select it with http_version => '2' in connect_websocket(). If only HTTP/2 has WebSocket enabled, the method defaults to HTTP/2; if HTTP/1.1 WebSocket is also enabled, the default remains HTTP/1.1.
my $client = Unblock::HTTP::Client->new(
http2 => { websocket => 1 },
connect_stream => sub { ... },
defer => sub { ... },
);
my $operation = $client->connect_websocket(
url => 'wss://example.com/chat',
http_version => '2',
on_open => sub {
my ($ws, $tx) = @_;
$ws->send_text('hello over HTTP/2');
},
on_message => sub {
my ($ws, $message, $type) = @_;
},
);
The Client waits for the server's first HTTP/2 SETTINGS frame before issuing Extended CONNECT, and requires the peer to advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. A peer that does not advertise support causes an explicit operation error without submitting CONNECT. The request is never automatically downgraded or replayed; one waiting connection does not cause repeated TCP connection attempts. The framework defer hook must schedule work for a later event-loop turn, not execute inline during an HTTP/2 callback.
The HTTP/2 WebSocket handshake uses CONNECT with the websocket :protocol pseudo-header and expects status 200. On successful response validation the callback on_open($ws, $tx) receives the real WebSocket Client and the original H2 Transaction. The operation's websocket() accessor returns the same WebSocket. Incoming DATA is dispatched to that WebSocket's input(). Outgoing WebSocket frame buffers are accepted by an internal per-stream bridge and sent through $tx->write() on a later deferred turn. Stream output congestion waits for on_drain(); graceful WebSocket Close eventually sends request END_STREAM. Errors and cancellation reset only the affected H2 stream.
Unlike HTTP/1.1 Upgrade, HTTP/2 Extended CONNECT never transfers the socket or removes it from the pool. Other ordinary HTTP and WebSocket operations can use independent streams on the same connection. on_finish therefore runs when the H2 Transaction actually ends, not when the WebSocket handshake succeeds. shutdown() allows any active WebSocket streams to finish before releasing the retiring shared connection, consistent with ordinary active HTTP/2 requests. The framework continues to feed the H2 engine normally; unblock_upgrade() is neither used nor required for HTTP/2.
HTTP/3 WEBSOCKET EXTENDED CONNECT
Opt in with http3 => { websocket => 1 } and select http_version => '3' with connect_websocket():
my $client = Unblock::HTTP::Client->new(
http3 => { websocket => 1 },
connect_quic => sub { ... },
defer => sub { ... },
);
my $operation = $client->connect_websocket(
url => 'wss://example.com/chat',
http_version => '3',
on_open => sub {
my ($ws, $tx) = @_;
$ws->send_text('hello over QUIC');
},
on_message => sub {
my ($ws, $payload, $type) = @_;
},
);
When HTTP/3 is the only enabled WebSocket protocol, connect_websocket() selects version 3 by default; otherwise the default precedence is HTTP/1.1, HTTP/2, HTTP/3. Version 3 requires an HTTPS origin (a wss URL), and uses a strict connection: it never falls back to TCP or replays a dispatched CONNECT.
The existing connect_quic factory supplies a ready, authenticated Net::QUIC connection. The unified Client starts the HTTP/3 engine and waits for the CURRENT server SETTINGS frame before attempting Extended CONNECT. The server must advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1. An H3 connection awaiting SETTINGS does not cause new QUIC connection attempts for the same queued WebSocket. An explicit lack of support fails the unsent operation with on_error and on_finish and does not send CONNECT. Remembered 0-RTT SETTINGS alone do not authorize this WebSocket admission path.
The WebSocket client handshake expects HTTP 200. After validation on_open($ws, $tx) receives the real WebSocket engine and actual H3 Transaction; $operation->websocket() returns that same engine. Outgoing WebSocket frames use the existing deferred per-stream H2/H3 DATA bridge, with streaming write(), on_drain() and end(). Incoming DATA calls the WebSocket's input() directly. defer must not execute callback work inline within QUIC or H3 receive callbacks. A refused or invalid HTTP/3 handshake records an operation error and defers stream-local cancellation, without throwing out of the QUIC receive dispatch. The operation reports state() eq 'error' and invokes on_error($tx, $message) once; the actual H3 Transaction passed to on_finish reports cancelled because the stream is intentionally reset, not because the HTTP/3 parser discovered a protocol error. This is the same distinction as an active request timeout. Ordinary HTTP3 requests are unaffected.
The QUIC connection remains multiplexed and pooled throughout. A WebSocket occupies one H3 request stream; independent WebSockets and ordinary requests can share that QUIC connection. QUIC bidi credit exhaustion requeues the SAME unsent operation, waking only when the existing engine reports new request credit; it does not open a new connection, discard bytes or replay a sent request. Cancellation or errors terminate only the relevant stream. Client shutdown() retires the pooled QUIC connection after its accepted active streams finish; graceful WebSocket Close sends END_STREAM. The operation's on_finish observes the final H3 transaction state, not the successful opening HTTP 200.
LIMITATIONS
Cross-host Alt-Svc, retries after dispatch, DNS HTTPS discovery and optional framework convenience classes are not implemented. Frameworks retain their network transports and use their normal writable readiness/QUIC timer paths. This branch requires the coordinated receive/lifecycle engine feature branches, not released engines.
LICENSE
MIT License.