Module http/client
Async HTTP client — the one-shot fetch/fetch_with, shaped like
JavaScript's fetch, and the connection-pooling HttpClient, shaped like
reqwest::Client.
fetch(url, io) is a GET with defaults; fetch_with(url, opts, io) takes
a FetchOptions carrying the method, headers, body, redirect cap,
response ceiling and a deadline for the WHOLE exchange. Both speak
plaintext over TcpStream and TLS over TlsStream (https is real TLS,
not a stub), and both buffer the entire response into memory — which is
why max_response_bytes exists and defaults to 64 MiB rather than being
unlimited.
The free functions stay ONE-SHOT: each call is a fresh connection and says
so on the wire with a Connection: close request header. HttpClient
OWNS its idle connections and reuses them, keyed by scheme+host+port, and
its Drop closes the idle set. Failures throw IoExn carrying an
HttpError (D1); a deadline that wins the race throws HttpError.Timeout
and aborts the in-flight task, so a hung server cannot pin a caller
forever.
{ fetch, HttpClient } :: import("std/http");
{ IoExn } :: import("std/error");
e := IoExn(io : io, exn : exn);
resp := io.await(fetch(`http://example.com/api/data`, io), e);
println(resp.body);
client := HttpClient.new(); // reuses connections
a := io.await(client.fetch(`http://example.com/one`, io), e);
b := io.await(client.fetch(`http://example.com/two`, io), e);
Stability
unstable — the pool is new (2026-09-11) and is what this release is asking
for use on before freezing. HttpClient's knobs (idle cap, idle timeout)
and the exact retry-on-a-stale-pooled-connection rule are the parts most
likely to move; fetch/fetch_with are expected to keep their one-shot
behaviour and their signatures.
A second, smaller shape is open: a plaintext request connects to the
FIRST address lookup_host returns and does not fall back to the next on
failure, so a host whose IPv6 route is dead fails instead of reaching its
IPv4 address (no happy-eyeballs, and the resolver order decides). That
sits behind std/net/dns's own open questions.
Types
Options for configuring an HTTP request.
Fields
| Name | Type | Description |
|---|---|---|
method | HttpMethod | |
headers | HeaderMap | |
body | String | |
timeout | Option(Duration) | Deadline for the WHOLE request, redirects included — |
max_redirects | usize | How many 3xx |
max_response_bytes | usize | Ceiling on the raw response bytes (headers + body) buffered into
memory before |
impl(FetchOptions, ...)
new : (FetchOptions) fn() -> FetchOptionsCreate default options (GET, no headers, empty body, no timeout, 10 redirects, 64 MiB response ceiling).
Returns: FetchOptions
with_method : (FetchOptions) fn(self : &FetchOptions, method : HttpMethod) -> FetchOptionsSet the HTTP method.
Parameters
| Name | Type | Notes |
|---|---|---|
self | FetchOptions | |
method | HttpMethod |
Returns: FetchOptions
with_header : (FetchOptions) fn(self : &FetchOptions, name : &String, value : &String) -> FetchOptionsAdd a request header.
Parameters
| Name | Type | Notes |
|---|---|---|
self | FetchOptions | |
name | String | |
value | String |
Returns: FetchOptions
with_body : (FetchOptions) fn(self : &FetchOptions, body : &String) -> FetchOptionswith_timeout : (FetchOptions) fn(self : &FetchOptions, limit : Duration) -> FetchOptionsGive the whole request (redirects included) a deadline.
Parameters
| Name | Type | Notes |
|---|---|---|
self | FetchOptions | |
limit | Duration |
Returns: FetchOptions
with_max_redirects : (FetchOptions) fn(self : &FetchOptions, n : usize) -> FetchOptionsCap the number of redirect hops followed (0 = return 3xx verbatim).
Parameters
| Name | Type | Notes |
|---|---|---|
self | FetchOptions | |
n | usize |
Returns: FetchOptions
with_max_response_bytes : (FetchOptions) fn(self : &FetchOptions, n : usize) -> FetchOptionsCap the raw response bytes buffered into memory (0 = unlimited).
Parameters
| Name | Type | Notes |
|---|---|---|
self | FetchOptions | |
n | usize |
Returns: FetchOptions
An HTTP client that OWNS a pool of idle keep-alive connections
(reqwest::Client's model, not a process-global one — a std with global
mutable state has no way to say when those sockets close).
Requests made through a client reuse an idle connection to the same target
when there is one, and put it back afterwards unless the exchange said
otherwise (RFC 9112 §9.3 — see _response_keeps_alive). The free fetch
and fetch_with do NOT use a client: they open a connection, announce
Connection: close and close it, exactly as before.
A connection is only ever reused for the same scheme, host and port —
the pool key. Handing an https://a connection to a request for
https://b would speak to the wrong peer under the first peer's
certificate, so the key is checked, never inferred.
Dropping the client closes every idle connection, and close_idle() is
the same operation under a name a caller can invoke (Go's
Transport.CloseIdleConnections).
A client is a move-only value (its pool is a Box): one owner closes the
pool, and a copy is E0901. fetch borrows it until the request is
awaited; to share one client between tasks, hold it as Rc(HttpClient).
Example
client := HttpClient.new();
a := io.await(client.fetch(`http://127.0.0.1:8080/one`, io), e);
b := io.await(client.fetch(`http://127.0.0.1:8080/two`, io), e); // same socket
client.close_idle(); // both closed
Stability
unstable — new, fetch, fetch_with, with_max_idle,
with_idle_timeout, idle_count and close_idle are intended to keep
their names and meanings; the pool's policies (a TOTAL idle cap, expiry
on the next request rather than on a timer, closing the connection in hand
rather than evicting one already pooled, and retrying only idempotent
methods) are implementation choices that may change.
Fields
| Name | Type | Description |
|---|---|---|
_cell | Box(_HttpClientState) |
impl(HttpClient, ...)
new : (HttpClient) fn() -> HttpClientA client with an empty pool, DEFAULT_MAX_IDLE_CONNECTIONS idle slots
and a DEFAULT_IDLE_TIMEOUT_SECS age limit.
Returns: HttpClient
_pool : (HttpClient) fn(self : &HttpClient) -> *(_HttpClientState)The pool, written through &HttpClient (see the type's doc).
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient |
Returns: *(_HttpClientState)
with_max_idle : (HttpClient) fn(self : HttpClient, n : usize) -> HttpClientCap the idle connections held at once (0 disables reuse).
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient | |
n | usize |
Returns: HttpClient
with_idle_timeout : (HttpClient) fn(self : HttpClient, limit : Duration) -> HttpClientSet the idle age limit (Duration.from_secs(0) lifts it).
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient | |
limit | Duration |
Returns: HttpClient
idle_count : (HttpClient) fn(self : &HttpClient) -> usizeHow many connections are currently idle in the pool.
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient |
Returns: usize
close_idle : (HttpClient) fn(self : &HttpClient) -> unitClose every idle connection — Go's Transport.CloseIdleConnections.
Connections currently carrying a request are unaffected; they are owned
by their exchange and are closed or pooled when it finishes.
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient |
Returns: unit
impl(HttpClient, ...)
fetch : (HttpClient) fn(self : &HttpClient, url_str : &String, io : Io) -> Impl(Future(HttpResponse, IoExn))GET url_str, reusing an idle connection to the same scheme/host/port
when the pool has one and returning it to the pool afterwards.
The future borrows the client (the exchange reaches its pool): await it where it is made.
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient | |
url_str | String | |
io | Io |
Returns: Impl(Future(HttpResponse, IoExn))
fetch_with : (HttpClient) fn(self : &HttpClient, url_str : &String, opts : &FetchOptions, io : Io) -> Impl(Future(HttpResponse, IoExn))fetch_with's options, over this client's connection pool.
Parameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient | |
url_str | String | |
opts | FetchOptions | |
io | Io |
Returns: Impl(Future(HttpResponse, IoExn))
Functions
fetch_with's options, over this client's connection pool.
Parameters
| Name | Type | Notes |
|---|---|---|
url_str | &String | |
opts | &FetchOptions | |
io | Io |
Returns: Impl(Future(HttpResponse, IoExn))
GET url_str, reusing an idle connection to the same scheme/host/port
when the pool has one and returning it to the pool afterwards.
The future borrows the client (the exchange reaches its pool): await it where it is made.
Parameters
| Name | Type | Notes |
|---|---|---|
url_str | &String | |
io | Io |
Returns: Impl(Future(HttpResponse, IoExn))
Constants
Default redirect cap — what browsers and curl use.
Value: 10
Default response ceiling: 64 MiB.
Value: 67108864
Default idle-connection cap: 16.
It is a TOTAL cap, not a per-target one, because the resource it protects is the process's descriptor table: a client that fans out over a thousand hosts would otherwise hold a thousand sockets open with a per-host cap of
- 16 is enough for several concurrent tasks against a handful of targets, which is what a single-threaded event loop can actually keep busy.
Value: 16
Default idle age limit: 30 seconds.
A pooled connection can only be reused until the SERVER's own idle timeout closes it, and that number is not knowable from here — nginx defaults to 75 s, Apache to 5 s. Expiring at 30 s throws away connections a short-timeout server has probably already dropped, which is the half of the race a client can win on its own; the retry below covers the rest.
Value: 30