Skip to content
You are reading next, which describes the unreleased compiler. Khora is unstable before v1: syntax, standard-library APIs and behavior may change. The current release is documented under v0.4.

Build an HTTP service

Khora’s shipped HTTP router works directly with Request, Response, Router, and shareable handler functions. A small service does not need an application framework before it can route requests.

This service exposes /health and /hello?name=... and listens on port 8080:

module main;
import std::core::{ChildFailed, Option, SharedFn};
import std::net::http::{HttpError, Request, Response, Router};
fn health(_request: Request) -> Response {
Response::text(200, "ok")
}
fn hello(request: Request) -> Response {
let name = match Request::query(request, "name") {
Option::Some(value) => value,
Option::None => "world",
};
Response::text(200, "hello ${name}")
}
pub fn main()
raises HttpError + ChildFailed
{
Router::new()
|> Router::get("/health", SharedFn::of(health))
|> Router::get("/hello", SharedFn::of(hello))
|> Router::listen(8080)!
}

A route handler is an ordinary direct-style function from Request to Response:

fn hello(request: Request) -> Response

SharedFn::of certifies the handler for the router’s concurrent serving boundary. The router can then invoke the handler from request fibers without turning the handler into a special async type.

Read request data once it reaches the handler

Section titled “Read request data once it reaches the handler”

The HTTP layer parses the request before routing it. Handlers can read the normalized path, matched route parameters, query values, headers, and body directly from Request.

For example, a route with a path parameter can inspect it through request.params:

fn show_user(request: Request) -> Response {
match Params::get(request.params, "id") {
Option::Some(id) => Response::text(200, "user ${id}"),
Option::None => Response::text(400, "missing id"),
}
}

and mount it with:

Router::new()
|> Router::get("/users/:id", SharedFn::of(show_user))

Return transport decisions at the HTTP boundary

Section titled “Return transport decisions at the HTTP boundary”

A handler should translate application outcomes into HTTP status codes and response bodies at the boundary. Domain functions below the handler can keep their own typed failures instead of knowing about status code 404 or 503.

For typed request/response bodies, continue with JSON API. For failure translation before the HTTP boundary, see Typed failure with raises.

Two verbs the router answers without being mounted

Section titled “Two verbs the router answers without being mounted”

A GET route is also a HEAD route. The router runs the GET handler and sends the headers alone — including the Content-Length of the body a GET would have sent, which is the whole point of the method. RFC 9110 requires the two answers to agree, and running the same handler is the only arrangement in which they cannot drift. Nothing above needs a second mount for curl -I, a cache, or a health checker that sends one.

OPTIONS is answered with 204 and an Allow header naming what the path mounts, plus OPTIONS itself and HEAD wherever GET is mounted — the same list a 405 gives, because a client cannot be told two things about one path and act on both. A path nothing mounts is 404 rather than a 204 with an empty Allow: “this path allows nothing” and “there is no such path” are different answers.

Mount either explicitly and the default steps aside for that path. There are two reasons to:

  • Router::head, for a resource whose length or validators are cheap and whose body is not, where answering HEAD by building the body and throwing it away is the entire cost of the request. Mounting it separately gives up the guarantee that HEAD and GET agree, so it earns its keep only when that cost is real.
  • Router::options, for CORS. The default answer is the correct reply to the question OPTIONS asks, but it carries no Access-Control-Allow-Origin: the library cannot know which origins a service trusts, and both “none” and “any” are wrong defaults. A browser calling this service from another origin sends a preflight first, and a handler mounted here is what answers it — including the Allow header the default would have sent, since the mount replaces the default rather than adding to it. The actual GET or POST response needs Access-Control-Allow-Origin too: the preflight authorizes the request, it does not authorize the answer.
fn preflight(req: Request) -> Response {
Response::text(204, "")
|> Response::with_header("Access-Control-Allow-Origin", "https://app.example")
|> Response::with_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|> Response::with_header("Access-Control-Allow-Headers", "content-type")
|> Response::with_header("Allow", "GET, POST, HEAD, OPTIONS")
}

A handler is called on a different fiber per connection, so anything they hold in common has to be shareable. Shared<Dict<K, V>> is the shape, and it is worth saying because the maps a handler already has in hand are not: request.params, request.queries and request.headers are Map, Map is a record with mut fields, and a mutable record cannot cross into a fiber:

error: `Map<String, Int>` does not implement `Share`, which `Shared::of` requires

Dict is the persistent one — an insert gives back a new dictionary and leaves the old one alone — so it holds nothing writable and goes into a cell. Build the state before the router, and let each handler close over it:

module main;
import std::core::{ChildFailed, Dict, Option, Shared, SharedFn};
import std::net::http::{HttpError, Request, Response, Router};
/// One counter per name, in a cell every request fiber shares.
fn greet(visits: Shared<Dict<String, Int>>, request: Request) -> Response {
let name = match Request::query(request, "name") {
Option::Some(value) => value,
Option::None => "world",
};
let seen = Shared::update(visits, fn table =>
Dict::insert(table, name, match Dict::get(table, name) {
Option::Some(n) => n + 1,
Option::None => 1,
}));
match Dict::get(seen, name) {
Option::Some(n) => Response::text(200, "hello ${name} (${n})"),
Option::None => Response::text(500, "lost the count"),
}
}
pub fn main()
raises HttpError + ChildFailed
{
let visits = Shared::of(Dict::new());
Router::new()
|> Router::get("/hello", SharedFn::of(fn request => greet(visits, request)))
|> Router::listen(8080)!
}

SharedFn::of takes a closure literal, and the closure captures visits — so the state reaches the handler as an ordinary parameter. A capability reaches one the same way, which is the parameter form: SharedFn has no capability row, so a handler that needs a database or an HTTP client takes it as an argument and the closure closes over it.

Shared::update reads, changes and writes under one lock, which is what makes the read-modify-write above correct when two requests for the same name arrive at once; two separate get and set calls would not be. Use Shared::modify when the change has something to report beyond the new state — the key it generated, say.

A cell is process-local. It does not survive a restart and a second instance does not see it, and a trap in a handler ends the process. So this is for what a restart can rebuild — a cache, a counter, a connection registry — and anything else belongs in a database.

Bound the resource that is actually constrained

Section titled “Bound the resource that is actually constrained”

The server has one capacity number, not two. Router::listen runs its accept loop inside bounded_nursery(256, ...), and an accepted connection is a fiber that is inside your handler for as long as the handler runs. There is no second, smaller pool that handlers queue for, so that one number is both the most connections served at once and the most handlers running at once. It is not configurable in this release.

That bound is not usually what limits throughput: a server saturates its cores well before it runs out of connection slots, and past saturation, raising a bound lengthens the queue while lowering it sheds load sooner — neither makes the server faster. Where the saturation point actually is for your handler on your machine is a measurement, and Performance sets out what a number has to carry before it is worth quoting: the ladder, the generator, the machine, the profile and the date. This page deliberately prints none, because it had one and it carried none of them.

So bound the thing that is actually scarce. If a handler waits on a database pool or a rate-limited API, put a smaller bound around that work rather than lowering the connection limit, which would refuse connections that could have been served. See Bounded concurrency.

The other number is per request rather than per connection, and it is configurable. A router holds at most 8 KB of one request — headers and body together — and answers 413 past it, before the handler runs. Router::holding(most) sets another:

Router::new()
|> Router::holding(1048576)
|> Router::post("/documents", SharedFn::of(store))
|> Router::listen(8080)!

The default is a policy about how much an unauthenticated client may make a server hold, not a limit of the parser. The buffer is allocated once at that size per connection, so the number you choose multiplies by the 256 above when deciding what a full server costs — raise it to what the largest legitimate document needs and not further.

Router::listen serves until the process stops. A SIGTERM or SIGINT stops it — the signal becomes a cancellation at the root, a parked accept observes it in about ten milliseconds, and scoped finalizers run on the way out. For an ordinary deploy that is the whole answer and this section is optional reading. It needs nothing of main: a cancellation stops any function, whatever its raises row.

A /shutdown route exists for the case the signal does not cover: a service that stops itself on a request. It runs listen on a fiber and lets go of that fiber when a route says to.

Cancel the listener, or detach it — both work. A canceled listener notices in the poll loop a parked accept sits in, unwinds, and releases its port.

cancel is the better of the two when you want the port released before the next line runs: it stops the listener and Fiber::wait tells you when that has happened. detach says “stop waiting on this” and returns at once, which is what you want when nothing afterwards depends on the socket being closed.

Drain first and stop last: stop accepting work, wait for what is in flight, then let go of the listener. Inside a main that has already created the jobs channel, spawned worker, bound port, and installed a Clock:

let stop = Shared::of(false);
let server = Fiber::spawn(fn () =>
Router::new()
|> Router::post("/shutdown", SharedFn::of(fn _r => {
Shared::set(stop, true);
Response::text(200, "stopping")
}))
|> Router::listen(port)!);
loop {
clock.sleep(50);
if Shared::get(stop) { break };
};
// Whatever the handlers feed: close it, and wait for the work already taken.
Channel::close(jobs);
Fiber::wait(worker)! catch { _ => eprint("the worker failed") };
// Only now.
Fiber::detach(server);

If worker raised, Fiber::wait raises its error, as Fiber::join would, and the catch is what lets the stop carry on. Without it main leaves at the wait, before the listener is let go of — and releasing server on the way out waits for a listener that never ends, so the program hangs instead of stopping. A failure handed back this way is not reported again. A fiber nobody waits for or joins is reported on standard error when its handle is let go of: khora: a fiber ended with an error nobody was waiting for. A canceled one is not reported. That line does not say when the worker failed — only when its handle went — so a supervisor that has to react while the program runs asks Fiber::finished and then joins, as below. Known limitations has every case.

Two things are worth being deliberate about. A request that arrives during the drain finds a closed channel, so Channel::send answers false — count it where it is refused, or the job is accepted and never seen again; the same reconciliation taking work off a queue is about, one layer up.

A program does not exit while a listener fiber is still running. Releasing a Fiber handle waits for the child — that is the guarantee that stops a fiber outliving the scope that owns it — and a listener never ends on its own. So a main that spawns a listener and then finishes its other work hangs at the end, after its last line, with nothing printed to say why.

Cancel the listener before main returns, or detach it if the program is exiting anyway:

Fiber::cancel(server);
Fiber::wait(server)!;

A listener that had already failed — its port would not bind, say — is not stopped by the cancel, because there is nothing left to stop, and Fiber::wait(server)! raises its HttpError out of main. That is usually what you want at the end of a program: it exits non-zero with the error. To finish regardless, write Fiber::wait(server)! catch { _ => () }.

This is the same fact as “drain first, stop last” seen from the other side: the listener is a child like any other, and somebody has to end it.

A listener that cannot bind does not stop the process on its own. Router::listen raises inside the fiber that Fiber::spawn started, and nothing reaches main: the failure is reported on standard error only when the handle is let go of, and a main polling a stop flag holds the handle for as long as it polls. So it is told nothing it can act on and keeps polling. A second copy started on a busy port serves nothing and, under a supervisor that restarts on exit, never restarts.

Logging the port before you listen does not mitigate this: the line is printed whether or not the bind succeeded, so it reports a healthy start for a process listening on nothing.

Ask the fiber instead. Fiber::finished answers without waiting, which is the one question a supervisor loop needs:

let server = Fiber::spawn(fn () => Router::listen(router, port)!);
loop {
clock.sleep(50);
if Fiber::finished(server) {
// A listener that stopped without being asked never started. `join` has
// the error; nothing below this is worth doing.
//
// Ask `canceled` first. A fiber that was told to stop has no answer, and
// `join` on one ends the program at 130 rather than handing back the
// `HttpError` this branch is written to report.
if Fiber::canceled(server) { break };
error("the listener stopped on its own");
Fiber::join(server)!;
break
};
if Shared::get(stop) { break };
};

That turns a process that hung for ever into one that exits non-zero with the HttpError on stderr, which is what a supervisor can see. Probing your own port once after spawning works too and proves a little more — that the socket answers, not just that the fiber is alive:

match HttpClient::send(client, Request::get("http://127.0.0.1:${port}/health")) {
Result::Err(_) => {
eprint("could not bind ${port}");
Fiber::detach(server);
return 1
},
Result::Ok(_) => info("listening on ${port}"),
};

Restarting on the same port inside a minute is fine: the listener sets SO_REUSEADDR, so a port left in TIME_WAIT by the previous run binds straight away. A port held by a live process still fails, as it should.

For a container, a SIGTERM unwinds the program and runs its finalizers, so the in-program half is covered. Draining at the layer above — out of the load balancer, wait, then stop — is still what Containers recommends, because it is what stops new requests arriving.

The router turns a typed failure into a 500 rather than a dropped connection. It cannot do that for a trap. A checked overflow, an index outside an array or a division by zero does not unwind, so it never reaches the wrapper, and the process exits with status 134 taking every in-flight connection with it.

The practical consequence is that request-shaped integers must be validated before they are used in arithmetic, which is what Decoding input is for, and that a service wants more than one process behind it. Traps has the whole of it.

For the complete router, request, response, and client surface, see the HTTP API reference.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource