HTTP
Coming from Python / Node? This is your Flask / Express moment — but instead of decorators or a routes table, you write a handler locus: a locus with a…
Coming from Python / Node? This is your Flask / Express moment — but instead of decorators or a routes table, you write a handler locus: a locus with a
handle(req) -> Responsemethod.std::http::Serverruns the accept loop and calls your handler per request. Routing is anifon the path insidehandle, orstd::http::Routeronce there are more than a few routes.
A server
Section titled “A server”locus Api { params { hits: Int = 0; }
fn handle(req: std::http::Request) -> std::http::Response { if req.path == "/health" { return std::http::Response { status: 200, body: "ok\n", content_type: "text/plain" }; } self.hits = self.hits + 1; return std::http::Response { status: 200, body: f"hello — hit #{self.hits}\n", content_type: "text/plain" }; }}
fn main() { // A statement-position locus literal fires its lifecycle: this // runs the accept loop until the process is stopped. std::http::Server { port: 8080, handler: Api { } };}hale build it, run it, and curl localhost:8080/health. The
handler’s params persist across requests — self.hits counts
them — because the Api locus is alive for the whole run.
The pieces
Section titled “The pieces”std::http::Requestcarriesmethod,path,version,body, and headers (looked up case-insensitively). For a couple of routes youmatch/ifonmethodandpathright in the handler; past that, use the Router (below).std::http::Responseneeds at leaststatusandbody;content_typedefaults totext/plain, and you can add customheaders.std::http::Servertakes aportand ahandler, then owns the listen-accept-parse-dispatch loop.max_accepts: Nbounds it to N requests (handy for tests); the default runs until stopped. POST bodies are reassembled perContent-Length, so clients that write headers and body in separate segments (python’s urllib, for one) work; requests are capped at 1 MiB (oversized declared bodies get a413).
Serving many requests at once
Section titled “Serving many requests at once”The server above answers one request at a time: fn main()’s
thread runs the accept loop, and while one handler works — a
sleep, a call to another service, a minute-long job — every
other request waits, health checks included. To serve requests
concurrently, make the server a field of a main locus and place
it on an async_io pool:
locus Api { params { hits: Int = 0; }
fn handle(req: std::http::Request) -> std::http::Response { if req.path == "/slow" { std::time::sleep(1s); // parks this request only } self.hits = self.hits + 1; return std::http::Response { status: 200, body: f"hit #{self.hits}\n" }; }}
main locus App { params { web: std::http::Server = std::http::Server { port: 8080, handler: Api { } }; } placement { web: cooperative(pool = web) where async_io; } run() { // the server runs on its own pool; main keeps the program up while !self.draining { std::time::sleep(100ms); } }}
fn main() { App { };}That one placement line is the switch. On an async_io pool every
connection runs on a coroutine of its own: when a handler parks — a
sleep, a socket read, an outbound plain-HTTP call, a slow client
on the write — the pool runs the other requests and keeps accepting.
Five concurrent requests to /slow take one second instead of five.
At most max_connections (256 by default) are in flight at once;
the next ones wait for a slot (as many again), and past that a
connection is closed unserved. Anywhere
else — fn main(), the main pool, pinned, a plain
cooperative(pool = io) — the server stays one-at-a-time, because a
blocking call there holds the whole thread. (macOS has no async_io
pools; a server there is one-at-a-time.)
What it means for your handler’s params: every request calls the
same Api locus, and on one thread, so two handle calls never run
at the same time. But they can interleave at a park: while
one request sleeps, another runs and bumps self.hits. Code between
two parks runs uninterrupted; a value you read before a park may have
changed after it. Read state again after a sleep or a call out
rather than trusting a copy from before it.
Blocking calls are the thing to avoid in a concurrent handler —
https requests, std::process::run, file reads hold the pool’s one
thread, and with it every request. hale check warns about a
blocking call reachable from an async_io locus.
To stop the server from another locus, give it a name (unique in
the process: a name another server holds fails the second one at
birth; without one, the name is 127.0.0.1:<the port it bound>) and
call std::io::tcp::stop(name) — a plain function, so it works from any
pool (calling self.web.shutdown() from App’s pool is refused: a
method call may not cross pools). Stopping ends the accept and lets
the requests already accepted finish: the server’s run() returns once
each has been answered.
// in App: `web: std::http::Server = std::http::Server { port: 8080, name: "web", handler: Api { } };`std::io::tcp::stop("web");The listener and the serving, placed apart
Section titled “The listener and the serving, placed apart”std::http::Server is two pieces in one locus: a listener that
accepts connections and hands each one over the bus, and a consumer
that serves what it is handed. You can place the two yourself — the
listener pinned on a thread of its own, the serving on async_io
pools — and put more than one consumer behind one listener:
locus Api { params { tag: String = ""; } fn handle(req: std::http::Request) -> std::http::Response { return std::http::Response { status: 200, body: self.tag }; }}
main locus App { params { // consumers first: they are listening before the listener accepts zero: std::io::tcp::Connections = std::io::tcp::Connections { listener: "web", shard: 0, handler: std::http::Responder { handler: Api { tag: "zero" } } }; one: std::io::tcp::Connections = std::io::tcp::Connections { listener: "web", shard: 1, handler: std::http::Responder { handler: Api { tag: "one" } } }; acceptor: std::io::tcp::Acceptor = std::io::tcp::Acceptor { port: 8080, name: "web", shards: 2 }; } placement { zero: cooperative(pool = zero) where async_io; one: cooperative(pool = one) where async_io; acceptor: pinned; } run() { while !self.draining { std::time::sleep(100ms); } std::io::tcp::stop("web"); }}
fn main() { App { };}The listener deals its connections round-robin over its shards; each
Connections takes the shard it names and serves it with its
handler — std::http::Responder turns an HTTP handle into one, and
any locus with fn serve(c: std::io::tcp::Accepted) is one too (it
owns c.fd). A connection no consumer takes is closed by its listener,
never leaked, so require no_silent_loss(topic __TcpAcceptedT) holds
in a program’s claims. Neither piece grows with the connections it
serves: each connection is served in a child that goes with it, and
the handle the listener sends is gone once the consumer has taken it.
std::io::tcp::Acceptor { path: "/run/app.sock" } listens on a Unix
socket instead of a port (its name is unix:/run/app.sock, and it
removes the file when it goes); the consumers are the same, since a
connection’s descriptor reads and writes alike for both. The api
runtime’s http::Rpc, mcp::Rpc, unix::Rpc and grpc::Rpc
listeners and a ws::Hub’s are built this way too (see The API
surface). A handler whose
per-connection work is a locus that subscribes to the bus starts it as
a flow child of its own — declare accept and release for it on the
handler — rather than building it inside serve: a locus born and gone
within a method has its deliveries queued for the main thread.
Below HTTP, std::io::tcp::Listener is the same two pieces with a
function for a handler: each connection is handed to on_connection
as a std::io::tcp::Stream, closed when the function returns.
max_accepts defaults to 1 (pass -1 to serve until stopped), and the
placement decides the concurrency exactly as for Server:
fn echo(s: std::io::tcp::Stream) { let line = s.recv(1024) or ""; s.send(line) or discard;}
main locus App { params { echo: std::io::tcp::Listener = std::io::tcp::Listener { port: 9000, max_accepts: -1, on_connection: echo }; } placement { echo: cooperative(pool = echo) where async_io; } run() { while !self.draining { std::time::sleep(100ms); } }}
fn main() { App { };}Routing
Section titled “Routing”When the if ladder in a handler grows past a few routes, hand
the routing to std::http::Router. The lightest registration is
a bare function — no handler locus needed:
fn greet(ctx: std::http::Context) -> std::http::Response { let who = std::http::path_param(ctx.params, "name"); return std::http::Response { status: 200, body: "hi " + who };}
router.add_fn("GET", "/hello/:name", greet);A locus handler (add) buys you state — counters, a database
child, config params — and both forms share one list and one
first-match-wins precedence order. The router keeps what add
(and use) hand it: it holds the handler as a borrow and never
owns it, so the handler has to live as long as the router does.
Make each handler a field of the locus that owns the router, and
fill the router in birth() — register patterns, get path captures
and query params extracted, and mount the owner as the server’s
handler —
locus Hello { fn handle(ctx: std::http::Context) -> std::http::Response { let who = std::http::path_param(ctx.params, "name"); return std::http::Response { status: 200, body: "hi " + who }; }}
// The routes' owner: each handler is a field, so it lives exactly as// long as the router that dispatches to it.locus Routes { params { hello: Hello = Hello { }; router: std::http::Router = std::http::Router { }; } birth() { self.router.add("GET", "/hello/:name", self.hello); } fn handle(req: std::http::Request) -> std::http::Response { return self.router.dispatch(req); }}
fn main() { std::http::Server { port: 8080, handler: Routes { } };}A handler literal built in a function that returns the router, or
in a function handed someone else’s router, would be reclaimed with
that function’s frame while the router still dispatches to it, so
hale check (and hale build) refuses it and names the rule (GH
#1048). Building and using a router in one function — r.add(…, Hello { }); r.dispatch(…) — is sound: the handler and the router end
together. Not inside a loop, though: a handler built in a loop body
is reclaimed when the next iteration reuses its storage, so register
handlers that are fields, or build each one outside the loop.
:name segments capture (path_param), ?k=v pairs are one
query_param(ctx.params, "k") away, the first matching route
wins (register specific patterns before general ones), and
anything unmatched hits an overridable 404. Middleware wraps the
chain in onion order:
locus Cors { fn before(ctx: std::http::Context) -> std::http::Context { return ctx; } fn after(ctx: std::http::Context, resp: std::http::Response) -> std::http::Response { return std::http::Response { status: resp.status, content_type: resp.content_type, headers: "Access-Control-Allow-Origin: *", body: resp.body }; }}// on the owner: a field `cors: Cors = Cors { };`, and in `birth()`// `self.router.use(self.cors);` — `use` keeps its middleware as `add` doesEach route’s handler is its own locus, so per-route state lives
where it belongs (a hits counter on the route that counts, not
on a god-handler).
One locus, many endpoints
Section titled “One locus, many endpoints”Sometimes the opposite shape is right: one API locus owning one
piece of state — a database handle, a session table — with every
endpoint as a method on it. For that, skip the Router and
self-dispatch with std::http::build_context + std::http::is_route:
locus Api { params { hits: Int = 0; }
fn handle(req: std::http::Request) -> std::http::Response { let ctx = std::http::build_context(req); if std::http::is_route(ctx, "GET", "/users") { return self.list(ctx); } if std::http::is_route(ctx, "GET", "/users/:id") { return self.show(ctx); } return std::http::Response { status: 404, body: "not found" }; }
fn list(ctx: std::http::Context) -> std::http::Response { self.hits = self.hits + 1; return std::http::Response { status: 200, body: "users" }; }
fn show(ctx: std::http::Context) -> std::http::Response { let id = std::http::path_param(ctx.params, "id"); return std::http::Response { status: 200, body: "user " + id }; }}
fn main() { std::http::Server { port: 8080, handler: Api { } };}build_context(req) builds the same per-request bundle the Router
would (query string split into ctx.params.qs); is_route
matches method + pattern with the Router’s own matcher — :name
captures land in ctx.params on a hit, and any miss leaves
them cleared, so the if ladder is first-match-wins in written
order with no stale captures. The endpoint methods are plain
direct calls with self in scope: state without ceremony.
Don’t reach for a fancier router on performance instinct: at
service-sized route counts (tens of routes) this linear scan
costs about 90 ns per route it tries (measured worst case: the
request matches the last route), a microsecond or so at ten
routes, next to what the request spends in parsing and syscalls. The shape also pairs
naturally with replicated placement:
under pinned(..., replicas = K) each replica is its own Api
instance, so params state — that database handle — is
per-thread by construction.
A first taste of interfaces
Section titled “A first taste of interfaces”How does Server know Api is a valid handler? Server’s
handler field has the type std::http::Handler, which is an
interface — a named set of required methods:
// (declared in the standard library)interface Handler { fn handle(req: Request) -> Response;}Any locus that has a matching handle method satisfies
Handler — automatically, with no implements clause to write.
This is structural satisfaction: the shape is the contract. You
declared Api with the right method, so it’s a Handler. (Go
programmers will recognize this; it’s interfaces without the
impl ceremony.)
Taking over the connection
Section titled “Taking over the connection”Some protocols start as HTTP and then stop being HTTP — WebSocket is the famous one. For those, a handler can take over the raw connection instead of finishing the request/response cycle:
type Conn { fd: Int; }topic WsConn { payload: Conn; subject: "ws.conn"; }
locus WsHandler { bus { publish WsConn; } fn handle(req: std::http::Request) -> std::http::Response { // req.conn_fd is the live socket. Hand it to whoever // will own the session (typically: publish it to a // session locus on its own pool). WsConn <- Conn { fd: req.conn_fd }; return std::http::Response { status: 101, headers: "Upgrade: websocket\r\nConnection: Upgrade", body: "", takeover: true }; }}With takeover: true the server writes just the status line and
your headers — no Content-Length, no Connection: close — and
then leaves the socket open and forgets it. It’s yours: read
and write it through the raw-fd std::io::tcp functions
(send_fd(fd, bytes) to write, recv_into to read, close_fd
to hang up) or a borrowed
Stream { conn_fd: fd, owns_fd: false }, and close it
when the session ends. Three things to remember: the server’s 5s
receive timeout is still set on the fd (clear it with
std::io::tcp::set_recv_timeout(fd, 0) for a long-lived
session); a stalled peer — a half-dead connection, a throttled
browser tab — will block your sends forever unless you bound
them, so set std::io::tcp::set_send_timeout(fd, 200ms) before
entering a push loop (an SSE/WS fan-out stalls its whole loop on
one dead client otherwise); and a takeover response without
stashing req.conn_fd leaks the connection.
And sometimes the handler doesn’t know the answer yet — the response will be produced later, by a different locus (a resolved promise, a bus reply, a job completing). For that, take the connection with nothing written:
return std::http::Response { status: 0, body: "", takeover_raw: true };With takeover_raw: true the server writes no status line and no
headers — the wire is untouched. Whoever ends up owning the fd
writes the entire response, status line included:
std::io::tcp::send_fd(fd, std::bytes::from_string( "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")) or discard;std::io::tcp::close_fd(fd);status, headers, and body on the response are ignored. The
same three caveats as takeover apply — plus one more: until you
write, the client is waiting on a silent connection, so bound the
deferral or arm your own timeout.
Calling out
Section titled “Calling out”Outbound requests are one call:
let resp = std::http::get("https://example.com") or raise;println(std::str::from_bytes(resp.body));
let posted = std::http::post("http://api.local/things", std::bytes::from_string("{\"n\": 1}"), "application/json") or raise;Both are fallible(std::http::HttpError) — address the error with
or raise / a substitute / an error-check fn, and branch on
err.kind (connect_failed, bad_url, …) when it matters.
Response bodies are Bytes (binary-safe; std::str::from_bytes
when you know it’s text). For repeated calls to the same host,
std::http::Client { keep_alive: true } pools connections and
retries with backoff:
let c = std::http::Client { keep_alive: true, max_retries: 2 };let r = c.get("http://api.local/health") or raise;One placement note: https calls block their thread (the
client’s TLS handshake and reads don’t park on async_io) — keep
loci that make them on pinned or an ordinary cooperative pool,
not an async_io one.
The bindings that wire a server across processes, and the lifecycle that lets a server shut down cleanly
on Ctrl-C are all next-level topics — but the handler you wrote
above doesn’t change when you get there. The server code is
already complete; the surrounding tier just gives it more ways to
be deployed and supervised.
Next: the transports below HTTP — UDP & TLS.