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.urlcan 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/wsswhen 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.