HTTP client
Crate hopf-http: client face of an HTTP Stream. Implement ClientHandler, wrap with ClientHandlerFactory, and dial via connect_http (hostname-aware H1/H2), H1Endpoint::client / H2Endpoint::client, or connect_h3.
Contents
ClientHandler SPI
| Trait | Role |
|---|---|
ClientHandlerFactory |
create_handler() -> Box<dyn ClientHandler> per outbound Stream |
ClientHandler |
Drive one request and receive the response |
ClientWriter |
Outbound request / in-flight control |
Callback sequence
start(&mut dyn ClientWriter)— write request headers ± bodyresponse_headersstart_response_body/response_body_content* /end_response_body(optional)response_trailers(H2/H3; default ignore — used by gRPC)response_complete
use hopf_http::{ClientHandler, ClientWriter, Headers};
struct GetOnce {
host: String,
path: String,
}
impl ClientHandler for GetOnce {
fn start(&mut self, request: &mut dyn ClientWriter) {
let mut h = Headers::new();
h.set(":method", "GET");
h.set(":path", &self.path);
h.set("host", &self.host);
h.set("connection", "close");
request.headers(h);
request.complete_request();
}
fn response_headers(&mut self, _request: &mut dyn ClientWriter, headers: &Headers) {
let _status = headers.status_code();
}
fn response_body_content(&mut self, _request: &mut dyn ClientWriter, data: &[u8]) {
let _ = data;
}
fn response_complete(&mut self, _request: &mut dyn ClientWriter) {}
}ClientWriter methods
| Method | Purpose |
|---|---|
headers(Headers) |
Buffer request headers (include :method, :path, …) |
start_request_body() |
Flush headers and begin the body (or end if empty) |
request_body_content(&[u8]) |
Write request body bytes |
end_request_body() |
Finish the request body |
trailers(Headers) |
Buffer request trailers after the body (H3: second HEADERS before FIN; H1 ignores; H2 not wired yet) |
complete_request() |
Finish the request (flushes headers if no body was started) |
For distributed tracing, wrap writers with hopf_otel::with_traceparent / PropagatingClientWriter when using hopf-otel.
HttpClient session API
A second, higher-level client face (Gumdrop HTTPClient): HttpClient (builder + connect), HttpConnectionHandler (connection lifecycle), HttpClientSessionHandle (request factory: get/post/put/…), and HttpRequest (headers, deferred body). Version (H1/H1.1-negotiated-H2/H2-prior-knowledge) is transparent to application code.
use std::sync::Arc;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_http::{HttpClient, HttpClientSessionHandle, HttpConnectionHandler, HttpResponseHandler};
struct Conn;
impl HttpConnectionHandler for Conn {
fn on_security_established(&mut self, info: &hopf_core::SecurityInfo) {
println!("TLS: {:?} via {:?}", info.protocol(), info.alpn());
}
fn on_connected(&mut self, session: &mut HttpClientSessionHandle) {
session.get("/status").send(Box::new(Resp)).unwrap();
}
fn on_error(&mut self, err: &std::io::Error) {
eprintln!("connect/transport failed: {err}");
}
}
struct Resp;
impl HttpResponseHandler for Resp {
fn ok(&mut self, status: u16) { println!("status {status}"); }
fn error(&mut self, status: u16) { println!("status {status}"); }
fn header(&mut self, _name: &str, _value: &str) {}
fn response_body_content(&mut self, data: &[u8]) { let _ = data; }
fn close(&mut self) {}
fn failed(&mut self, err: std::io::Error) { eprintln!("request failed: {err}"); }
}
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
HttpClient::new("api.example.com", 443)
.tls(connector, "api.example.com")
.connect(&rt, Box::new(Conn))?;
Several requests at once
On HTTP/2 the session accepts any number of bodyless requests (send) without waiting for earlier ones to complete: each opens its own stream, in the order accepted, as the peer's SETTINGS_MAX_CONCURRENT_STREAMS allows (the rest queue and open as streams finish). A request body streams for one request at a time, since request_body_content carries no request id — a second start_request_body before the first body's end_request_body is refused with request body already in flight; bodyless requests may still join the queue meanwhile. HttpClientSessionHandle::supports_multiplexing() says which case you are in: on HTTP/1.1 a second request of any kind while one is in flight is refused with request already in flight. HttpClientSessionHandle is Clone, so on_connected can hand the connection to another thread or a later task; requests from any clone share the one connection, and a send made off the reactor thread is followed by conn_handle().poke() to flush it (see below).
Deferred request body
HttpRequest::start_request_body / request_body_content / end_request_body stream a request body in caller-chosen chunks — on both H1 and H2, bytes reach the wire as each chunk arrives rather than being buffered until the whole body is known:
| Method | Behaviour |
|---|---|
start_request_body(handler) | H1: flushes request headers and opens chunked encoding. H2: opens the stream and sends HEADERS immediately (no END_STREAM) — it does not wait for end_request_body the way a one-shot ClientWriter::start call does. |
request_body_content(data) -> Result<usize, HttpClientError> | Queues data and returns the number of bytes actually accepted — a short write (less than data.len(), possibly 0) when the connection's bounded outbound buffer (H1) or the H2 stream's flow-control backlog is currently full, rather than growing unboundedly. Never blocks. |
on_body_writable(callback) | One-shot resume signal, fired once more bytes can be accepted after a short write. Register it in response to a short write and retry the remainder from the callback instead of polling. |
end_request_body() | Marks the body complete; the final bytes (and END_STREAM on H2) go out once anything still queued has drained. |
Cross-connection streaming (claim-check / tee pattern)
Because request_body_content just queues bytes, it's safe to call from a thread or connection other than this one — the common case being another protocol connection (e.g. SMTP DATA) teeing its incoming bytes straight into an HTTP PUT without holding the whole message in memory on either side. Stash the hopf_core::ConnHandle handed to on_connected via HttpClientSessionHandle::conn_handle(), and call .poke() on it after each out-of-band request_body_content / end_request_body call to ask this connection's own reactor to flush the newly queued bytes without waiting for its own next I/O event:
// In on_connected: stash session.conn_handle() and the HttpRequest
// somewhere the other connection's callback can reach (e.g. Arc<Mutex<_>>).
//
// From the SMTP connection's message_content callback, once bytes arrive:
request.request_body_content(&chunk)?;
conn_handle.poke(); // wakes the HTTP connection's reactor to flush now
//
// At end of message:
request.end_request_body()?;
conn_handle.poke();
ConnHandle::poke() is the same primitive storage-offload completion callbacks already use to make a paused connection re-check its own state (Endpoint::poke_handler) — it re-invokes this connection's receive path with no new bytes, which is exactly where both the H1 and H2 session codecs already flush anything queued.
Connection and request failure
HttpConnectionHandler::on_error fires for DNS failure, refused/reset connect, or a TLS handshake failure — anything that happens before on_connected could ever run. Once a request is in flight, its own HttpResponseHandler::failed fires instead (mid-connection resets/disconnects route through whichever of the two is still waiting to hear about it — never both, and never neither).
HttpConnectionHandler::on_security_established (Gumdrop onSecurityEstablished) fires once, right before on_connected, on TLS dials only — a plaintext HttpClient never calls it. Use it to inspect the negotiated hopf_core::SecurityInfo (ALPN, protocol version, cipher suite, and the server's certificate chain via peer_certificate_chain()) before the first request goes out; the default implementation is a no-op.
HttpClientTimeouts
| Field | Default | Applies to |
|---|---|---|
dns | 5s | Hostname resolution, via the resolver's own query timeout — only when HttpClient created the resolver itself (not when the caller supplied one via .resolver(...), which may be shared elsewhere) |
connect | 30s | TCP connect handshake |
stage | 60s | Budget for one request/response round trip once bytes are on the wire — renewed on outbound and inbound progress alike, so an actively-streaming request or response doesn't spuriously time out; a genuinely stalled peer fails the request via HttpResponseHandler::failed with TimedOut. Duration::ZERO disables it. |
Content-Encoding (br, gzip, deflate)
HttpClient applies content coding by default on every request made through connect or fetch, on HTTP/1.1, HTTP/2 and HTTP/3. Turn it off with HttpClient::disable_content_encoding(), or tune it by passing a ContentEncodingPolicy to HttpClient::content_encoding, HttpClientSessionHandle::content_encoding or HttpRequest::content_encoding. The low-level ClientHandler SPI is unaffected and always sees the wire bytes.
Responses: always ask, always decode
- Request:
Accept-Encoding: br, gzip, deflate, identity;q=0.5is added unless you set one yourself. It is left off forRange/If-Rangerequests,CONNECTand upgrades. - Response headers: when a body is decoded,
Content-EncodingandContent-Lengthare removed from the headers the handler sees (they describe the coded body it never receives). Every other header, the trailers and the status pass through unchanged. A response with no body (HEAD, 204, 304, empty) is forwarded untouched. - Streaming: decoding is an incremental push state machine with one fixed 16 KiB scratch buffer plus the coding’s window, so memory does not grow with the body and a body split into 1-byte segments decodes identically. Stacked codings (
gzip, br) are undone in reverse order. - Fail closed: an unknown coding, a corrupt or truncated stream, or decoded output past
HttpLimits::max_decoded_body(default 64 MiB) ends the request throughHttpResponseHandler::failedwithInvalidData, and no further body bytes are delivered.
Request bodies: only where the server is known to accept them
HTTP has no request-side negotiation, so the client never guesses. It records, per origin, the codings a server accepts in requests, in a ContentCodingCache:
- Learned from an
Accept-Encodingheader on any response from that origin (RFC 9110 §12.5.3), including a415that names what it accepts. A415that names nothing clears the entry. hopf servers send it on every response. - Unknown origin means uncompressed. The first request to a server that has not advertised anything goes out plain. Entries expire after an hour. Share a cache between clients with
HttpClient::coding_cache, which also lets you pre-seed a server you know. - When it compresses: the body has a compressible
Content-Type(text, JSON, XML and similar), is not declared shorter than 256 bytes, and the origin advertised a coding we also support (best ofbr,gzip,deflate).Content-Encodingis set andContent-Lengthremoved, so the request is sent chunked on HTTP/1.1. - Opting out: set
Content-Encodingyourself on the request. Any value,identityincluded, means the body is sent exactly as you give it. - Backpressure: compression is streamed.
request_body_contentreports a short write while a small backlog is still draining, andon_body_writableworks as documented above.
The codec layer is public as hopf_http::{Decoder, Encoder, ContentCoding} (push &[u8] in, receive output through a sink) for compressing or decompressing outside an HTTP exchange. Encoder::flush forces out everything pushed so far without ending the stream.
Dial patterns
Listen and dial are peers: the same Stream SPI runs under a TcpConnectorConfig factory or H3 connect_h3. Prefer connect_http when the peer is a hostname (async DNS + connect timeouts).
connect_http (hostname or literal)
use std::sync::Arc;
use hopf_http::{ClientHandlerFactory, HttpLimits};
use hopf_http::client::{connect_http, HttpClientTimeouts};
connect_http(
&rt, // &Arc<Runtime>
"example.com", // hostname or IP / SocketAddr string
80,
Arc::clone(&factory),
HttpLimits::default(),
false, // true → H2 prior-knowledge
HttpClientTimeouts::default(), // dns / connect / stage
None, // optional DnsResolver
)?;| Timeout | Default | Notes |
|---|---|---|
HttpClientTimeouts::dns |
5s | Ignored for literal IPs |
HttpClientTimeouts::connect |
30s | Wired into TcpConnectorConfig::connect_timeout |
HttpClientTimeouts::stage |
60s | Response-stage budget (headers, etc.) |
Returns immediately; DNS and the TCP/protocol handshake run on a worker. Demo: examples/http-get.
HTTP/1.1 cleartext (resolved addr)
use hopf_core::{ProtocolHandler, Runtime, TcpConnectorConfig};
use hopf_http::{H1Endpoint, HttpLimits};
use std::sync::Arc;
rt.connect(TcpConnectorConfig::new(addr, move || {
Box::new(H1Endpoint::client(
Arc::clone(&factory),
HttpLimits::default(),
false, // not TLS-from-dial at the H1 layer
)) as Box<dyn ProtocolHandler>
}))?;HTTP/2 prior-knowledge (h2c)
use hopf_http::H2Endpoint;
rt.connect(TcpConnectorConfig::new(addr, move || {
Box::new(H2Endpoint::client(
Arc::clone(&factory),
HttpLimits::default(),
false, // cleartext prior-knowledge
)) as Box<dyn ProtocolHandler>
}))?;TLS + ALPN
connect_https(rt, host, port, factory, tls_connector, server_name, settings) is the TLS counterpart of connect_http for the ClientHandler SPI: it resolves the name, dials with the given connector and installs an H2 or H1 client endpoint according to the ALPN the handshake negotiated (offer http/1.1 only, or no ALPN, when the request must be an HTTP/1.1 Upgrade, as a WebSocket client's is). It needs no feature. By hand:
Build a SharedTlsConnector with hopf_core::connector_from_pem or hopf_core::public_trust_connector (ALPN list must match the server), pass it to TcpConnectorConfig::with_tls(connector, server_name), and use H2Endpoint::client / H1Endpoint::client or AlpnHttpEndpoint on the dial side so negotiated ALPN selects H2 vs H1. See TLS → Wiring.
HTTP/3
use hopf_http::{connect_h3, HttpLimits};
use hopf_quic::{client_config_from_pem, ALPN_H3};
let client_cfg = client_config_from_pem(ca_path, &[ALPN_H3])?;
let handle = connect_h3(
addr,
client_cfg,
"localhost", // SNI / cert name
factory,
HttpLimits::default(),
)?;Dial by name
connect_http resolves hostnames asynchronously (literal IPs skip DNS). Lower-level: hopf_dns::RuntimeDnsExt::connect_by_name on Arc<Runtime>. See clients.md and dns.md.
http-get flags
Demo binary: examples/http-get (crate http-get).
cargo run -p http-get -- [FLAGS] [ADDR] [PATH]
| Flag / arg | Meaning |
|---|---|
--http2 / --h2 |
Dial with H2Endpoint::client (cleartext prior-knowledge) |
--http3 / --h3 |
Dial with connect_h3 (requires --ca) |
--ca <pem> |
Trust store / leaf PEM for H3 (client_config_from_pem) |
--server-name <name> |
TLS server name for H3 (default localhost) |
ADDR |
Hostname or host:port (default 127.0.0.1:8080, or :4433 for H3) |
PATH |
Request path (default /) |
Typical pairing with servers:
# H1
cargo run -p http-hello -- 127.0.0.1:8080
cargo run -p http-get -- 127.0.0.1:8080 /
# H2 prior-knowledge
cargo run -p http-get -- --http2 127.0.0.1:8080 /
# H3
cargo run -p http3-hello -- 127.0.0.1:4433
cargo run -p http-get -- --http3 --ca "$TMPDIR/hopf-http3-hello/cert.pem" \
127.0.0.1:4433 /Examples
| Example | Role |
|---|---|
examples/http-get |
H1 / H2 / H3 GET client |
examples/http-hello |
Twin server for H1/H2 |
examples/http3-hello |
Twin server for H3 |
Limitations
- Stage-0
TcpConnectorConfigdials expect a pre-resolvedSocketAddr; preferconnect_httpfor hostnames. - H3 client opens one request Stream after handshake in the stock
H3ClientConnectionpath (suitable for the GET demo; not a pool). - Response trailers are delivered only on H2/H3.
- One request body streams at a time per session; only bodyless requests overlap, and only on H2.