std.http

Client and server in one module.

Client

import std.http

r = http.get(url)
print(r.status)
print(r.body)
print(r.headers)

http.post(url, body, headers)
data = http.get(url).body_bytes        # bytes, for binary
r = http.get(url, stream: true)        # or read the body as it arrives

http.put(url, opts), http.patch(url, opts) and http.delete(url, opts) cover the rest of the verbs. opts is the same options map every client call takes: body, json, headers, timeout (seconds, 60 by default), stream. A buffered response is { status, headers, body, body_bytes }; with stream: true it is { status, headers, body } and body is a stream.

Streaming a download

import std.fs
import std.http
import std.io

r = http.get(url, stream: true)
out = fs.open("release.tar.gz", "w")
for piece in r.body {
    io.write(out, piece)
}
io.close(out)

With stream: true the body is a stream of bytes, read from the connection as your program reads it. Nothing accumulates: a 4 GB download costs you one piece at a time. There is no body_bytes on a streamed response, since the whole point is that the whole body never exists at once.

for piece in r.body gives bytes. io.read_text and io.lines decode when the body is text, and hash.sha256(r.body) digests it as it arrives.

A buffered body larger than ECKO_MAX_ALLOC (256 MiB, see limits) is refused rather than read into memory you may not have, and the error tells you to pass stream: true. A content-length over the cap is refused before a byte is read; a chunked body is refused at the point it crosses.

timeout: bounds the connection, not the download. In stream mode it is the connect deadline plus a per-read deadline that resets every time the server sends more, so a slow-but-alive transfer of any size completes while a stalled one fails. io.timeout(r.body, ms) sets your own reader-side deadline on top of that.

Dropping a body you have not read to the end stops the transfer, but a server that has gone silent without closing the connection can hold the background reader until that same timeout: expires, 60 seconds by default. It ends on its own; lower timeout: if you abandon bodies often.

Server

fn handler(req) {
    if req.path == "/" { http.text("hello") } else { http.not_found() }
}

http.serve(8080, handler)

serve blocks. A handler takes a request map and returns a response. http.stop() requests a graceful shutdown from elsewhere - in-flight requests drain, then serve returns. Ctrl-C does the same thing.

http.port() is the port the running server bound, or null when none is running. It matters for http.serve(0, handler), which asks the OS for any free port: serve blocks, so the program never gets another turn to look, and the startup banner reports the real port rather than the 0 that was asked for. A handler - or a task spawned before serve - reads it back:

async fn probe() {
    mut p = null
    while p == null {
        sleep(0.02)
        p = http.port()
    }
    print("listening on {p}")
}

let t = probe()
http.serve(0, handler)

Which interface it binds

serve binds 0.0.0.0 by default, so the server is reachable on every interface the machine has - including whatever network you happen to be on. That is what a container wants and is usually not what a laptop wants. Pass host: to choose:

http.serve(8080, handler, host: "127.0.0.1")   # this machine only
http.serve(8080, handler, host: "0.0.0.0")     # every interface (the default)

If you are developing on a shared or untrusted network, set host: "127.0.0.1" rather than relying on a firewall.

Responses

http.text(s)
http.html(body)
http.json(value)
http.response(status, body, headers)
http.not_found()

Or build one as a map - { status, headers, body } - which is all a response is.

Requests

A request map carries method, path, params, headers, body, body_bytes, form, files. http.form(req) parses a submitted form and http.files(req) gives uploads with filename and content_type.

Routing

serve takes one handler. For routes, :params, middleware and static files, use std.web, which is a router over this:

import std.web
http.serve(8080, web.router(routes))

Concurrency

Handlers run on a worker pool (ECKO_HTTP_WORKERS, default 8), and each handler gets a snapshot of captured state - the usual share-nothing model. To share across requests, use a cell:

hits = cell(0)
fn handler(req) {
    n = cell_update(hits, fn(v) v + 1)
    http.text("request #{n}")
}

Sizing the pool

The pool is a hard cap on how many handlers run at once, so the right size depends entirely on what your handlers do - and the wrong choice costs about 4x in either direction. Measured on a 32-core box, best of three runs:

ECKO_HTTP_WORKERShandler that computeshandler that waits 5ms
8 (default)570,000 req/s1,570 req/s
16525,000 req/s-
32401,000 req/s6,214 req/s

If your handlers compute, leave it alone. More workers than cores just adds contention: every extra worker is another thread competing for the same CPUs, and throughput falls off steadily.

If your handlers wait, raise it. A handler that queries a database, calls another service, or runs ai spends nearly all its time parked, holding a slot without using a core. Throughput there is roughly workers / handler time: at the default, a handler that takes 5ms caps the whole server at 1,600 requests a second no matter how many cores the machine has. Raising the pool to 32 in that test was 4x the throughput at a third of the p99 latency.

A reasonable starting point for a waiting server is the number of requests you want in flight at once - if a handler takes 20ms and you want 1,000 req/s, you need about 20 slots. Then measure, because the number that matters is your handler's real latency, not an estimate of it.

Streaming responses are the exception, and are covered next.

Streaming responses do not use a handler slot

A stream: or SSE response is drained on its own thread, so clients that hold a stream open do not reduce how many handlers can run. They are bounded separately by ECKO_MAX_STREAMS (default 1024); beyond that a streaming response is refused with a 503 that says so, rather than sent as an empty body you cannot tell from a stream that ended immediately.

A client that disconnects ends its drain promptly, without waiting for your program to produce another chunk - so a stream that has gone quiet is not holding anything open for a reader who left.

Limits

ECKO_HTTP_MAX_BODY (10 MiB) bounds request bodies - one over the limit is answered 413 Payload Too Large and never reaches the handler, so a handler cannot be given a clipped body that looks whole - and ECKO_HTTP_REQUEST_TIMEOUT_MS bounds a handler. Both matter on a public port: an unbounded body is a memory exhaustion, and an unbounded handler is a worker held forever.

ECKO_HTTP_READ_TIMEOUT_MS (default 30000) bounds how long a connection may take to send its request headers, so a client that opens a socket and dribbles bytes is dropped rather than holding a connection. That is a different limit from the handler timeout, which only starts once a request has fully arrived.

Protocols

HTTP/1.0 and 1.1, with keep-alive honoured and advertised in both directions. Chunked request and response bodies, 100-continue, trailers and pipelining all work. HTTP/2 is negotiated over TLS through ALPN, falling back to HTTP/1.1 for clients that cannot speak it; cleartext h2c is not offered.

Compression

Buffered responses over 1 KiB are compressed when the client asks for it with Accept-Encoding: gzip or deflate, gzip preferred. The response gains Content-Encoding and Vary: Accept-Encoding.

Three things are left alone: already-compressed content types (image/*, video/*, audio/*, zip), any response whose handler set its own Content-Encoding, and streaming responses. Streams are never compressed because buffering one to compress it would defeat the point of streaming.

Streaming and WebSockets

http.stream sends a body fed by a channel, which is how you push tokens as they arrive - see Streaming responses & SSE. For a WebSocket upgrade, see std.ws. For the client side - reading a response body as it arrives - see Streaming a download above.

TLS

Client requests to https:// verify certificates. The Linux builds link OpenSSL statically, so there is nothing to install.

Errors

Client failures raise { kind: "net", url, message }. Wrap calls to anything you do not control, and consider retry for transient failure:

body = retry(3, fn() http.get(url).body)