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

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

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

NameTypeDescription
_cellBox(_HttpClientState)
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

_pool : (HttpClient) fn(self : &HttpClient) -> *(_HttpClientState)

The pool, written through &HttpClient (see the type's doc).

Parameters

NameTypeNotes
selfHttpClient

Returns: *(_HttpClientState)

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

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

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_str&String
opts&FetchOptions
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.

The future borrows the client (the exchange reaches its pool): await it where it is made.

Parameters

NameTypeNotes
url_str&String
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