Module http/client

http/client
Stability: unstable — may still change; see below.

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

FetchOptions object
FetchOptions

Options for configuring an HTTP request.

Fields

NameTypeDescription
methodHttpMethod
headersHeaderMap
bodyString
timeoutOption(Duration)

Deadline for the WHOLE request, redirects included — .None waits forever. On expiry the in-flight connection is aborted and fetch throws HttpError.Timeout.

max_redirectsusize

How many 3xx Location hops to follow before throwing HttpError.TooManyRedirects. 0 returns the first 3xx verbatim.

max_response_bytesusize

Ceiling on the raw response bytes (headers + body) buffered into memory before HttpError.ResponseTooLarge is thrown; 0 = unlimited. This client buffers the whole response into a String, so the default (64 MiB) is what stands between an unbounded server and OOM.

impl(FetchOptions, ...)
new : (FetchOptions) fn() -> FetchOptions

Create 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) -> FetchOptions

Set the HTTP method.

Parameters

NameTypeNotes
selfFetchOptions
methodHttpMethod

Returns: FetchOptions

with_header : (FetchOptions) fn(self : FetchOptions, name : String, value : String) -> FetchOptions

Add a request header.

Parameters

NameTypeNotes
selfFetchOptions
nameString
valueString

Returns: FetchOptions

with_body : (FetchOptions) fn(self : FetchOptions, body : String) -> FetchOptions

Set the request body.

Parameters

NameTypeNotes
selfFetchOptions
bodyString

Returns: FetchOptions

with_timeout : (FetchOptions) fn(self : FetchOptions, limit : Duration) -> FetchOptions

Give the whole request (redirects included) a deadline.

Parameters

NameTypeNotes
selfFetchOptions
limitDuration

Returns: FetchOptions

with_max_redirects : (FetchOptions) fn(self : FetchOptions, n : usize) -> FetchOptions

Cap the number of redirect hops followed (0 = return 3xx verbatim).

Parameters

NameTypeNotes
selfFetchOptions
nusize

Returns: FetchOptions

with_max_response_bytes : (FetchOptions) fn(self : FetchOptions, n : usize) -> FetchOptions

Cap the raw response bytes buffered into memory (0 = unlimited).

Parameters

NameTypeNotes
selfFetchOptions
nusize

Returns: FetchOptions

HttpClient object
HttpClient

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

NameTypeDescription
_idleArrayList(_Idle)
max_idleusize

Maximum idle connections held at once. When the pool is full a finished connection is closed instead of stored; 0 disables reuse altogether.

idle_timeoutDuration

How long a connection may sit idle before it is closed rather than reused. Duration.from_secs(0) lifts the limit.

Trait Implementations

impl(HttpClient, ...)
new : (HttpClient) fn() -> HttpClient

A 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) -> HttpClient

Cap the idle connections held at once (0 disables reuse).

Parameters

NameTypeNotes
selfHttpClient
nusize

Returns: HttpClient

with_idle_timeout : (HttpClient) fn(self : HttpClient, limit : Duration) -> HttpClient

Set the idle age limit (Duration.from_secs(0) lifts it).

Parameters

NameTypeNotes
selfHttpClient
limitDuration

Returns: HttpClient

idle_count : (HttpClient) fn(self : HttpClient) -> usize

How many connections are currently idle in the pool.

Parameters

NameTypeNotes
selfHttpClient

Returns: usize

close_idle : (HttpClient) fn(self : HttpClient) -> unit

Close 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

NameTypeNotes
selfHttpClient

Returns: unit

_take : (HttpClient) fn(self : HttpClient, key : String) -> Option(_Idle)

Parameters

NameTypeNotes
selfHttpClient
keyString

Returns: Option(_Idle)

_put : (HttpClient) fn(self : HttpClient, key : String, transport : _Transport, carry : Rc(ArrayList(u8))) -> bool

Parameters

NameTypeNotes
selfHttpClient
keyString
transport_Transport
carryRc(ArrayList(u8))

Returns: bool

impl(HttpClient, Dispose(...))
dispose : (HttpClient) fn(self : HttpClient) -> unit

Release 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

NameTypeNotes
selfHttpClient

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

NameTypeNotes
selfHttpClient
url_strString
ioIo

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

NameTypeNotes
selfHttpClient
url_strString
optsFetchOptions
ioIo

Returns: Impl(Future(HttpResponse, IoExn))

Functions

fetch_with function
fn(url_str : String, opts : FetchOptions, io : Io) -> Impl(Future(HttpResponse, IoExn))

fetch_with's options, over this client's connection pool.

Parameters

NameTypeNotes
url_strString
optsFetchOptions
ioIo

Returns: Impl(Future(HttpResponse, IoExn))

fetch function
fn(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

NameTypeNotes
url_strString
ioIo

Returns: Impl(Future(HttpResponse, IoExn))

Constants

DEFAULT_MAX_REDIRECTS constant usize

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

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