Skip to content
Kavachकवच

HTTP: Serving

● Intermediate4 min

A server is one function from Request to Response. Route by r.method and r.path; answer with http.text or http.json_response.

use std.http
use std.net

fn shop(r: Request) -> Response {
    if r.method == "GET" and r.path == "/" {
        return http.text(200, "Welcome to the shop")
    }
    if r.method == "GET" and r.path.starts_with("/items/") {
        const id = r.path.slice(7, r.path.len())
        return http.text(200, "item {id}")
    }
    if r.method == "POST" and r.path == "/orders" {
        return http.text(201, "order placed: {r.body.text() ?? ""}")
    }
    http.text(404, "nothing at {r.path}")
}

fn main() ! Error {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, shop)
    const base = "http://{listener.address()}"
    for path in ["/", "/items/42", "/basket"] {
        const page = try http.get("{base}{path}")
        print("GET {path}: {page.status} {page.body.text() ?? ""}")
    }
    const made = try http.post("{base}/orders", "2 pens".bytes())
    print("POST /orders: {made.status} {made.body.text() ?? ""}")
}

The program ran and printed:

kavach run routes.kav

GET /: 200 Welcome to the shopGET /items/42: 200 item 42GET /basket: 404 nothing at /basketPOST /orders: 201 order placed: 2 pens
routes.kav: Kavach code, then what running it prints
  • Each connection runs in a task of its own, so one slow client doesn't hold up the others.
  • The status says what happened: 200 found, 201 made, 404 not found, 400 a bad request.

Who sent it

r.peer is the address of whoever opened the connection, for limits per client. A request made by hand has "":

use std.http
use std.net

fn who(r: Request) -> Response {
    const local = r.peer.starts_with("127.0.0.1:")
    http.text(200, "a request from this machine: {local}")
}

fn main() ! Error {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, who)
    const r = try http.get("http://{listener.address()}/")
    print(r.body.text() ?? "")
    const made = Request(method: "GET", path: "/", headers: [], body: Bytes([]))
    print("by hand: \"{made.peer}\"")
}

The program ran and printed:

kavach run peer.kav

a request from this machine: trueby hand: ""
peer.kav: Kavach code, then what running it prints

Behind a proxy, peer is the proxy's address. Trust an address the proxy forwards in a header only when the request came from a proxy you list.

With mutual TLS, r.client_cert holds the names on the client's certificate, for allowing callers by name, and none otherwise. See TLS.

tested

Headers

Headers are a list of Header(name:, value:), in the order they are sent, names in lower case. A name may come more than once: two set-cookie lines stay two headers.

use std.http
use std.net

fn login(r: Request) -> Response {
    const client = r.header("User-Agent") ?? "unknown"
    Response(
        status: 200,
        headers: [
            Header(name: "set-cookie", value: "session=abc; HttpOnly"),
            Header(name: "set-cookie", value: "theme=dark"),
        ],
        body: "hello, {client}".bytes(),
    )
}

fn main() ! Error {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, login)
    const r = try http.get("http://{listener.address()}/")
    print(r.body.text() ?? "")
    for cookie in r.headers_named("set-cookie") {
        print(cookie)
    }
    print(r.header("x-missing") ?? "no x-missing")
}

The program ran and printed:

kavach run headers.kav

hello, unknownsession=abc; HttpOnlytheme=darkno x-missing
headers.kav: Kavach code, then what running it prints
  • r.header(name) gives the first value of a name, or none. The name can be in any case.
  • r.headers_named(name) gives every value of a name, in order.

Traces

http.serve runs each handler in a span named by the request's method (GET), never by its path, which can hold ids and tokens. When the request has a W3C traceparent header, the span continues that trace, and http.request and the other calls send the current span's. So traces join across services with no code:

use std.http
use std.log
use std.net

fn handler(r: Request) -> Response {
    // The handler runs in a span that continues the caller's trace.
    // The span is named `GET`; the handler knows its route, so it says it.
    log.name_span("GET /orders/\{id}")
    http.text(200, log.trace_id() ?? "none")
}

fn main() ! Error uses Net, Console {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, handler)
    const url = "http://{listener.address()}/orders/7"

    log.span("checkout") {
        const mine = log.trace_id() ?? ""
        const got = try http.get(url)
        print("one trace across both: {(got.body.text() ?? "") == mine}")
    }
}

The program ran and printed:

kavach run traces.kav

one trace across both: true
traces.kav: Kavach code, then what running it prints

A handler that knows its route names the span with it, with log.name_span. The route's pattern is one of a few names; the raw path would be a new one for every order. \{ keeps the brace as text, since { starts interpolation.

A server the internet reaches directly sets follow_trace: false in its ServeLimits. Each request then starts a trace of its own, so a client can't choose trace ids or join its requests to someone else's trace:

use std.http
use std.log
use std.net

fn whose(r: Request) -> Response {
    http.text(200, log.trace_id() ?? "none")
}

fn main() ! Error uses Net, Console {
    const listener = try net.listen("127.0.0.1:0")
    const limits = ServeLimits(follow_trace: false)
    const _server = spawn http.serve(listener, whose, limits: limits)
    const url = "http://{listener.address()}/"

    log.span("caller") {
        const mine = log.trace_id() ?? ""
        const got = try http.get(url)
        const theirs = got.body.text() ?? ""
        print("a trace of its own: {theirs.len() == 32 and theirs != mine}")
    }
}

The program ran and printed:

kavach run own-trace.kav

a trace of its own: true
own-trace.kav: Kavach code, then what running it prints

See Logging for spans, and for sending them to a collector.

Limits

A server faces clients that are slow, large or hostile, so http.serve holds each one to ServeLimits. Every field has a safe default, so http.serve(listener, handler) is already protected. Change only the ones you need:

use std.http
use std.net

fn upload(r: Request) -> Response {
    http.text(200, "got {r.body.len()} bytes")
}

fn main() ! Error {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, upload, limits: ServeLimits(max_body_bytes: 10))
    const url = "http://{listener.address()}/"
    const small = try http.post(url, "hello".bytes())
    print("{small.status}: {small.body.text() ?? ""}")
    const big = try http.post(url, "x".repeat(50).bytes())
    print(big.status)
}

The program ran and printed:

kavach run limits.kav

200: got 5 bytes413
limits.kav: Kavach code, then what running it prints
pub type ServeLimits {
    pub max_header_bytes: Int = 16_384        // 431 past it
    pub max_body_bytes: Int = 10_000_000      // 413 past it, refused by `content-length` before the body is read
    pub buffered_body: Int = 1_000_000        // larger bodies are read by the handler, `r.read_body()`
    pub header_timeout: Duration = 10.seconds // 408 when a request's line and headers take longer
    pub min_body_rate: Int = 1_000            // bytes a second, after a second's grace: 408 when slower
    pub idle_timeout: Duration = 5.seconds    // a kept-open connection waiting for its next request
    pub max_connections: Int = 10_000         // 503 past it
    pub max_in_flight: Int = 1_000            // requests being answered at once: 503 past it
    pub retry_after: Int = 1                  // what `retry-after` says on a 503, in seconds
    pub drain_timeout: Duration = 10.seconds  // on a stop signal, how long to finish the requests under way
    pub follow_trace: Bool = true             // continue a request's `traceparent`; `false`: a trace of its own
}

const server = spawn http.serve(listener, upload, limits: ServeLimits(max_body_bytes: 100_000_000))
syntax: Kavach code
  • A request that breaks a limit gets its status and the connection is closed. The handler never sees it.
  • A client that sends its headers a byte at a time holds a task for at most header_timeout.
  • A request with expect: 100-continue gets 100 Continue once its size is known to fit, before it sends the body.
  • A 503 says retry-after, so well-behaved clients wait before trying again.

Stopping

On Ctrl+C or SIGTERM (how Docker and Kubernetes stop a container), http.serve answers new connections with 503, closes those waiting for their next request, and finishes the requests under way, for at most drain_timeout. Then the program ends, without cutting a response off. A second signal ends it at once. See When a program stops.

A client that leaves

While a request is answered, http.serve keeps reading its connection. When the client closes it, the handler's task is cancelled, so a request nobody waits for stops using the server. A handler that panics gets 500, and the server goes on.

tested

HTTP/2 without TLS

http.serve answers HTTP/2 too, from a client that starts with the HTTP/2 preface without TLS, as services calling each other do. One connection carries many requests at once, and each runs its handler in a task of its own, so a slow request holds up no other:

use std.http
use std.http.h2
use std.net
use std.time

/// Asks for `path`, and prints the answer as soon as it comes.
fn show(c: H2Client, path: String) ! H2Error uses Net, Console {
    const reply = try c.request([
        Header(name: ":method", value: "GET"),
        Header(name: ":scheme", value: "http"),
        Header(name: ":authority", value: "localhost"),
        Header(name: ":path", value: path),
    ], Bytes([]))
    print("got {path}: {reply.body.text() ?? "?"}")
}

fn main() ! Error uses Clock {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, r => {
        if r.path == "/report" {
            time.sleep(300.ms)      // a slow request
        }
        print("answered {r.path}")
        http.text(200, "done")
    })
    const c = try h2.connect(try net.connect(listener.address()))
    const slow = spawn show(c, "/report")
    const fast = spawn show(c, "/ping")
    try slow.wait()
    try fast.wait()
    c.close()
}

The program ran and printed:

kavach run h2-at-once.kav

answered /pinggot /ping: doneanswered /reportgot /report: done
h2-at-once.kav: Kavach code, then what running it prints
  • /report was asked first, but /ping was answered first: its handler didn't wait for the slow one.
  • On the client, each task gets its answer as soon as it comes, whatever order the requests were sent in.
  • At most 100 requests are answered at once on a connection, and the server tells the client so.
  • The handler sees the same Request and gives the same Response as over HTTP/1.1.
tested

Large bodies

A body up to buffered_body (1 MB unless you say) is read whole into r.body. A larger one is left for the handler to read as it comes, a piece at a time, with r.read_body(), which gives none once it has all come. An upload of any size then needs no more memory than a piece:

use std.http
use std.net

fn upload(r: Request) -> Response uses Net {
    let read = 0
    while true {
        const piece = r.read_body() catch _ => return http.text(400, "upload cut off")
        read += (piece ?? break).len()
    }
    http.text(200, "in r.body: {r.body.len()}, read as it came: {read}")
}

fn main() ! Error {
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, upload, limits: ServeLimits(buffered_body: 100))
    const url = "http://{listener.address()}/"
    for size in [5, 3000] {
        const r = try http.post(url, "x".repeat(size).bytes())
        print("{size} bytes sent: {r.body.text() ?? ""}")
    }
}

The program ran and printed:

kavach run upload.kav

5 bytes sent: in r.body: 5, read as it came: 03000 bytes sent: in r.body: 0, read as it came: 3000
upload.kav: Kavach code, then what running it prints
  • The 5-byte body fits in buffered_body: 100, so it is all in r.body, and read_body has nothing more.
  • The 3000-byte body is not in r.body: the handler reads it with read_body.
  • A read fails with a NetError when the client goes away during the upload.

Sending a file

http.file(path) answers with a file as it is read, so a file of any size is sent without being held in memory. Its content-type comes from the file's extension, and its content-length from its size:

use std.fs
use std.http
use std.net

fn download(r: Request) -> Response uses Fs.read {
    http.file("report.txt") catch _ => http.text(404, "no such file")
}

fn main() ! Error {
    try fs.write_text("report.txt", "sales up\n".repeat(1000))
    const listener = try net.listen("127.0.0.1:0")
    const _server = spawn http.serve(listener, download)
    const r = try http.get("http://{listener.address()}/")
    print(r.header("content-type") ?? "?")
    print(r.header("content-length") ?? "?")
    print(r.body == (try fs.read_bytes("report.txt")))
    try fs.remove("report.txt")
}

The program ran and printed:

kavach run file.kav

text/plain; charset=utf-89000true
file.kav: Kavach code, then what running it prints

To send something else as it is read, give a Response a reader from fs.chunks as its stream: Response(status: 200, headers: [], body: Bytes([]), stream: reader).

Forms with files

An HTML form with files sends a multipart/form-data body. http.form(r) reads it a part at a time, and each part's data as it comes, so a large upload is never all in memory:

fn upload(r: Request) -> Response ! HttpError uses Net, Console {
    const form = try http.form(r)
    let received = 0
    while true {
        const part = (try form.next()) ?? break
        if part.filename == none {
            print("{part.name} = {try part.text()}")
            continue
        }
        while true {
            received += ((try part.read()) ?? break).len()
        }
    }
    http.text(200, "received {received} bytes")
}
syntax: Kavach code
WrittenGives
form.next() -> FormPart? ! HttpErrorThe next part, or none after the last; data of the part before not read yet is skipped
part.name, part.filename, part.content_typeThe field's name, the file's name (String?, none for a plain field) and its content-type (text/plain when not given)
part.read() -> Bytes? ! HttpErrorThe part's next piece of data, or none at its end
part.text() -> String ! HttpErrorThe rest of the part as text: for small fields

A form that isn't multipart/form-data, has no boundary, or is cut off fails with BadMessage.