Skip to content

HTTP

Make buffered or streaming HTTP requests, serve connections, and speak WebSocket.

Updated View as Markdown

Every HTTP operation is a host call: it suspends, and it returns Result[T, HttpError]. Http is the stateless entry point; HttpClient, HttpBodyStream, HttpListener, HttpConnection, and WebSocket are opaque host resources that release themselves through Drop.

A request

fn fetch_status(url: string): int ! HttpError {
    resp := Http.get(url)?
    println("${resp.status} from ${resp.url}, ${resp.body.len()} bytes")
    return resp.status
}

fn main(): void {
    match fetch_status("https://example.test/") {
        Ok(code) => println("got ${code}")
        Err(ConnectionRefused { url }) => println("refused by ${url}")
        Err(DnsError { host, message }) => println("dns ${host}: ${message}")
        Err(e) => println("transport failure")
    }
}

HttpError covers only transport failures: ConnectionRefused, ConnectionTimeout, RequestTimeout, DnsError, TlsError, InvalidUrl, TooManyRedirects, NetworkError, and Other.

error ApiError { NotFound, Rejected { status: int }, Transport }

fn read_user(url: string): string ! ApiError {
    resp := match Http.get(url) {
        Ok(r) => r
        Err(e) => error Transport
    }
    if resp.status == 404 {
        error NotFound
    }
    if resp.status >= 400 {
        error Rejected { status: resp.status }
    }
    return resp.body.decode_utf8() ?? ""
}

fn main(): void {
    match read_user("https://api.example.test/users/1") {
        Ok(body) => println("${body.len()} chars")
        Err(NotFound) => println("no such user")
        Err(Rejected { status }) => println("server said ${status}")
        Err(Transport) => println("could not reach the server")
    }
}

Body bytes are never implicitly decoded. decode_utf8() returns a string?, so an invalid-UTF-8 body is an Option you must handle rather than a silent mojibake.

Verbs and bodies

Call Body argument
Http.get(url) —
Http.post(url, body) []byte
Http.put(url, body) []byte
Http.delete(url) —
Http.head(url) — (response has no body)
Http.get_stream(url) — (returns HttpBodyStream)

Request bodies are owned []byte. string.to_bytes() produces exactly that; string.bytes() produces a borrowed [..]byte and will not type-check here.

fn publish(url: string, json: string): int ! HttpError {
    created := Http.post(url, json.to_bytes())?
    replaced := Http.put(url, json.to_bytes())?
    probed := Http.head(url)?
    removed := Http.delete(url)?
    return created.status + replaced.status + probed.status + removed.status
}

fn main(): void {
    match publish("https://api.example.test/items/1", "{\"n\":1}") {
        Ok(total) => println("status total ${total}")
        Err(e) => println("failed")
    }
}

Response headers

The host normalizes response headers into one \n-separated block with lower-cased names, exposed as resp.headers. has_header(name) is a case-insensitive presence test.

fn describe(url: string): string ! HttpError {
    resp := Http.get(url)?
    if !resp.has_header("content-type") {
        return "untyped response"
    }
    return "headers block is ${resp.headers.len()} chars"
}

fn main(): void {
    match describe("https://example.test/") {
        Ok(line) => println(line)
        Err(e) => println("failed")
    }
}

There is no header(name): string? accessor yet — extracting a value needs string slicing, which is declared in the prelude but has no lowering path (ATOLL2004). Read the raw headers block until it lands.

The static helpers also accept no request headers. When authentication or content negotiation needs one, that must come from a host integration.

resp.url is the final URL, which differs from the requested one after a redirect. Anything that authorizes on host or path must read resp.url, not the string it passed in.

Reusable clients

Http.client(base_url) builds a host-owned client with pooled connections and relative-path verbs. Use one client for many calls to the same service.

fn sync(base: string): int ! HttpError {
    client := Http.client(base)
    profile := client.get("/v1/profile")?
    events := client.get("/v1/events")?
    client.post("/v1/ack", "{}".to_bytes())?
    client.put("/v1/profile", "{}".to_bytes())?
    client.delete("/v1/events/1")?
    client.close()
    return profile.status + events.status
}

fn main(): void {
    match sync("https://api.example.test") {
        Ok(total) => println("${total}")
        Err(e) => println("sync failed")
    }
}

HttpClient has get, post, put, delete — no head, and no per-request header builder. close() is synchronous and optional; Drop releases the pooled connections at scope exit.

Streaming a response body

Http.get_stream(url) suspends only until the response head arrives, then hands back an HttpBodyStream. Each next() yields the next run of bytes; an empty chunk is end-of-body and is never real content.

fn download_size(url: string): int ! HttpError {
    body := Http.get_stream(url)?
    mut total := 0
    for {
        chunk := body.next()?
        if chunk.is_empty() {
            break
        }
        total = total + chunk.len()
    }
    body.close()
    return total
}

fn main(): void {
    match download_size("https://example.test/large.bin") {
        Ok(n) => println("${n} bytes streamed")
        Err(e) => println("download failed")
    }
}

The streaming variant does not surface status or headers — use Http.get when you need them.

pump_into(stream) turns the body into a Stream[List[byte]] so it composes with select, race, and other streams. The caller owns the ring, so it chooses the capacity and can close it to abandon the download early. The pump applies backpressure: a fast body never outruns a slow consumer, and no chunk is dropped.

fn main(): void {
    match Http.get_stream("https://example.test/large.bin") {
        Ok(body) => {
            chunks := Stream.new[List[byte]](8)
            body.pump_into(chunks)
            mut total := 0
            for chunk in chunks {
                total = total + chunk.len()
            }
            println("${total} bytes via stream")
        }
        Err(e) => println("could not open body")
    }
}

The adapter closes the destination at EOF, when the consumer abandons it, and after a mid-body transport error — so a truncated download reads as a clean end-of-stream. When integrity matters, drive next() directly: it surfaces each chunk’s Result precisely.

Streaming bounds memory only if the consumer actually releases chunks. Appending every chunk to one list is a buffered request with extra steps.

WebSocket clients

fn subscribe(url: string): string ! HttpError {
    socket := WebSocket.connect(url)?
    socket.send("subscribe")?
    socket.send_bytes("ping".to_bytes())?

    text := socket.receive()?
    binary := socket.receive_bytes()?
    println("${binary.len()} binary bytes")

    socket.close()
    return text
}

fn main(): void {
    match subscribe("wss://events.example.test/socket") {
        Ok(msg) => println("first message: ${msg}")
        Err(e) => println("socket failed")
    }
}

send/receive carry text frames, send_bytes/receive_bytes carry binary ones. The surface exposes no ping, pong, or close-frame metadata, and close() releases the host halves without sending a close frame — send a graceful close yourself if the protocol demands one.

Servers

Http.listen(addr) binds an HTTP/1.1 listener; accept() yields one HttpConnection at a time. On a connection, request() reads and parses the whole request and respond(status, body) writes the reply.

fn serve(addr: string, rounds: int): int ! HttpError {
    listener := Http.listen(addr)?
    mut served := 0
    for i in 0..rounds {
        conn := listener.accept()?
        req := conn.request()?
        println("${req.method} ${req.url} (${req.body.len()} body bytes)")
        conn.respond(200, "ok\n".to_bytes())?
        conn.close()
        served = served + 1
    }
    listener.close()
    return served
}

fn main(): void {
    match serve("127.0.0.1:8080", 4) {
        Ok(n) => println("served ${n} requests")
        Err(e) => println("listener failed")
    }
}

HttpRequest carries method, url, and body — request headers are not part of the record. Note the loop bound: ? on accept() tears the whole server down on one bad connection, which is almost never what a real server wants. See the worked example below.

Streaming an inbound body

For uploads, request_head() parses only the request line and headers — the returned body is empty and the real body stays on the wire. receive_chunk() then pulls it piece by piece, with an empty piece marking end-of-body.

fn absorb(conn: HttpConnection): int ! HttpError {
    head := conn.request_head()?
    println("${head.method} ${head.url}, buffered body = ${head.body.len()}")

    mut received := 0
    for {
        piece := conn.receive_chunk()?
        if piece.is_empty() {
            break
        }
        received = received + piece.len()
    }

    conn.respond(200, "stored\n".to_bytes())?
    return received
}

fn main(): void {
    match Http.listen("127.0.0.1:8080") {
        Ok(listener) => {
            match listener.accept() {
                Ok(conn) => match absorb(conn) {
                    Ok(n) => println("${n} bytes uploaded")
                    Err(e) => println("upload failed")
                }
                Err(e) => println("accept failed")
            }
        }
        Err(e) => println("bind failed")
    }
}

Drain to EOF (or close() the connection) before the next request_head() on a keep-alive connection. A receive_chunk() with no prior request_head(), or after the body drained, is a safe empty.

Streaming an outbound body

respond_begin(status) writes the status line with Transfer-Encoding: chunked, each send_chunk writes one frame, and respond_end() writes the terminator. Call them in that order.

fn stream_reply(conn: HttpConnection, parts: []string): int ! HttpError

{
    conn.respond_begin(200)?
    mut sent := 0
    for p in parts {
        chunk := p.to_bytes()
        conn.send_chunk(chunk)?
        sent = sent + chunk.len()
    }
    conn.respond_end()?
    return sent
}

fn main(): void {
    mut parts: []string = List.new[string]()
    parts.add("alpha\n")
    parts.add("beta\n")

    match Http.listen("127.0.0.1:8080") {
        Ok(listener) => {
            match listener.accept() {
                Ok(conn) => match stream_reply(conn, parts) {
                    Ok(n) => println("streamed ${n} bytes")
                    Err(e) => println("stream failed")
                }
                Err(e) => println("accept failed")
            }
        }
        Err(e) => println("bind failed")
    }
}

An empty send_chunk is a no-op on purpose: an empty data frame would encode the terminator and end the response early. Signal end-of-body only with respond_end().

Upgrading to a WebSocket

conn.upgrade_websocket() reads the Upgrade: websocket request, writes 101 Switching Protocols, and takes owned possession of the socket. On success the HttpConnection is consumed — its handle is dead, and you use the returned WebSocket. On failure the connection is untouched and still usable for an ordinary respond.

fn echo_socket(conn: HttpConnection, rounds: int): int {
    socket := match conn.upgrade_websocket() {
        Ok(s) => s
        Err(e) => {
            match conn.respond(400, "not a websocket\n".to_bytes()) {
                Ok(_) => {}
                Err(e2) => {}
            }
            return 0
        }
    }

    mut echoed := 0
    for i in 0..rounds {
        msg := match socket.receive() {
            Ok(m) => m
            Err(e) => break
        }
        match socket.send("echo: ${msg}") {
            Ok(_) => { echoed = echoed + 1 }
            Err(e) => break
        }
    }
    socket.close()
    return echoed
}

fn main(): void {
    match Http.listen("127.0.0.1:8080") {
        Ok(listener) => {
            match listener.accept() {
                Ok(conn) => println("echoed ${echo_socket(conn, 8)} frames")
                Err(e) => println("accept failed")
            }
        }
        Err(e) => println("bind failed")
    }
}

A worked server

A real accept loop keeps running when one connection fails, routes on the request, and hands each connection to its own task so a slow client cannot block the listener.

fn route(req: HttpRequest): (int, string) {
    if req.url == "/health" {
        return (200, "ok")
    }
    if req.method != "GET" {
        return (405, "method not allowed")
    }
    return (404, "not found")
}

fn handle(conn: HttpConnection): int {
    req := match conn.request() {
        Ok(r) => r
        Err(e) => {
            conn.close()
            return 0
        }
    }

    status, text := route(req)
    match conn.respond(status, text.to_bytes()) {
        Ok(_) => {}
        Err(e) => println("could not write response")
    }
    conn.close()
    return status
}

fn main(): void {
    listener := match Http.listen("127.0.0.1:8080") {
        Ok(l) => l
        Err(InvalidUrl { url }) => {
            println("bad bind address: ${url}")
            return
        }
        Err(e) => {
            println("bind failed")
            return
        }
    }

    for i in 0..16 {
        match listener.accept() {
            Ok(conn) => {
                worker := spawn { handle(conn) }
                println("connection ${i} dispatched")
            }
            Err(e) => {
                println("accept failed; stopping")
                break
            }
        }
    }
    listener.close()
}

Cancellation, connection limits, and per-request timeouts are application policy — the prelude gives you the loop and the resources, not the shape of the server.

Resource lifetimes

Every listener, connection, client, body stream, and socket is a scoped host resource with a Drop impl. An ordinary return, a break, or a propagated ? still releases it. close() is synchronous (it adds no suspension point) and idempotent; call it when the resource must go before the end of a long-lived scope.

Effects

Everything that waits on the network carries Suspend, and callers inherit it by inference; fallible wrappers also carry Error. Neither is written down — the ATOLL2032 warnings this produces are expected and harmless. Closing is synchronous, so cleanup runs during ordinary scope exit without a new suspension point.

See Suspension for the effect boundary and Streams for backpressure semantics.

Security

Transport success says nothing about content trust. For requests derived from user input:

  • restrict schemes and destinations so a URL cannot reach the internal network;
  • account for redirects — resp.url can differ from the requested URL;
  • validate status, media type, size, and decoding before parsing;
  • do not reflect raw remote messages into trusted logs or responses;
  • use https/wss when confidentiality and peer authentication matter.

Buffered helpers materialize the whole body in guest memory and expose no per-call timeout or size cap. The host performs DNS and TLS, so certificate roots, proxy behavior, destination allow-lists, and timeouts are deployment policy. A typed response record does not replace those controls.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close