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 Dispose 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.
dispose closes every idle connection, and close_idle() is the same
operation under a name a caller can invoke (Go's
Transport.CloseIdleConnections). Call close_idle() when you are
done with a client: a client that has made a request does not currently
reach reference count zero, because a ref value passed as a parameter to
an io.async future is never released
(issues/fixed/a-ref-value-passed-to-an-async-future-is-never-released.md), so
the dispose on a scope exit does not yet fire. It is correct and will
start firing when that defect is fixed.
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 |
|---|---|---|
_idle | ArrayList(_Idle) | |
max_idle | usize | Maximum idle connections held at once. When the pool is full a
finished connection is closed instead of stored; |
idle_timeout | Duration | How long a connection may sit idle before it is closed rather than
reused. |
Trait Implementations
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
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
_take : (HttpClient) fn(self : HttpClient, key : String) -> Option(_Idle)_put : (HttpClient) fn(self : HttpClient, key : String, transport : _Transport, carry : Rc(ArrayList(u8))) -> boolParameters
| Name | Type | Notes |
|---|---|---|
self | HttpClient | |
key | String | |
transport | _Transport | |
carry | Rc(ArrayList(u8)) |
Returns: bool
impl(HttpClient, Dispose(...))
dispose : (HttpClient) fn(self : HttpClient) -> unitRelease the resources self owns — a file descriptor, a socket, a lock,
a buffer the allocator handed out. Called automatically when the value's
owner drops it (a value type) or the last reference to it goes away (a
reference type), so an implementor never calls it directly and must
tolerate being the only one who ever does.
It must be safe to run exactly once, and a type that also exposes an
explicit close/release is responsible for making the second call a
no-op.
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.
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.
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