SCUA

How-to

Serve HTTP requests

Answering HTTP requests — running a server — needs the serve capability. It's a different authority from making requests: --allow-net lets a script call out, but it never lets one open a port. Serving is its own grant, off by default, and until the host gives it, http.serve and net.listen simply don't exist — there's no name to call. So a script can't start listening by accident, and can't leak that power into an embedded build unless someone deliberately turns it on.

#Granting the capability

From the CLI, the grant names the exact address the script may bind:

scua --allow-serve=127.0.0.1:8080 serve.scua   # bind only this address:port

Without it, http.serve is an undefined name — the function isn't there to call. And the grant is a boundary: a script may only bind the address you named, not any port it likes. --allow-net never grants listening, however broad it is; the two capabilities don't overlap.

The host owns the hard parts — accepting connections, parsing requests, framing responses. Your script owns exactly one thing: the handler.

(If all you want is to look at some files in a browser, you don't need any of this — scua serve does that in one command, with no script and no grant to arrange. See Serve a directory over HTTP.)

#The minimal server

import http

fn handle(req)
  if req.method == "GET" and req.path == "/health" then
    return { status = 200, body = b"ok" }
  end
  return { status = 404, body = b"not found" }
end

http.serve(handle, { bind = "127.0.0.1:8080" })

http.serve(handler, { bind = "ADDR:PORT" }) serves requests until the process is asked to stop: Ctrl-C, or SIGTERM from whatever runs it. Then it finishes the requests already in flight, returns Ok({ drained, abandoned }), and the lines after it run (see Stopping a server). The handler is a plain function fn(req) -> resp: SCUA calls it once per request. The bind must be within the grant, and an option http.serve doesn't know is refused with a message naming the ones it does, so a misspelled option fails at the call instead of being ignored.

Nothing after the http.serve line runs while the server is up, so put your startup work before it.

When the server starts it writes where it is and how it is set up to stderr, URL first:

scua: serving on http://127.0.0.1:8080/ — 1 worker, up to 10000 connections (RLIMIT_NOFILE 1048576), stop budget 25 s
scua: timeouts: header 10 s, body 30 s (and 1024 B/s after 10 s), request 30 s, write 30 s, idle 75 s; backlog 4096; buffered requests up to 64 MiB

Each of those numbers is a flag you can change; the sections below say what they do.

#Each request runs on its own

Every request runs as if in a partition of its own: your handler starts from the same place each time, and nothing one request changes is there for the next. That is what lets the server answer many requests at once without any of them seeing another half-way through. By default it runs them on one core. Start the program with SCUA_SCHEDULER=threaded and --workers=N to spread them across N cores; that option is still experimental. In practice:

  • The handler can't reach the main script's lets. A table it only reads belongs in a const. Anything else it needs, pass in context (below). A handler that reaches a let is refused before the server starts, with each one listed and what to do about it.
  • A module's state starts as declared on every request. A request can change it, and the change is gone when the request ends.
  • A handler can wait. ask … timeout, wait_for, select, and calls such as http.get, fs.read or net.read wait for this request alone; the server goes on answering others meanwhile. A bare wait(d) is refused with a 500, as in any partition's handler.
  • A handler can spawn. The task runs after the response has gone out, which is how you answer now and finish later:
fn handle(req)
  spawn(fn()
    wait(50ms)
    expensive_followup(req.path)   -- runs after the response has gone out
  end)
  return { status = 202, body = b"queued" }
end

The rest of your program keeps running while the server is up, so a background job goes in a task spawned before the http.serve call, which does not come back until the server stops.

print from a handler works, and so do timers and cluster gossip — a clustered node keeps talking to its peers while it serves.

#Keep state in a server

Something that has to last from one request to the next, such as a count, lives in a partition, and the handler tells or asks it. Pass the partition in context: whatever you give as context is handed to the handler as its second argument, a fresh copy for each request.

import http
import bytes

partition Counter
  state hits = 0
  on Hit() hits = hits + 1 end
  ask Count() return hits end
end

fn handle(req, ctx)
  tell ctx.counter.Hit()
  if req.path == "/count" then
    let n = ask ctx.counter.Count() timeout 1s
    return { status = 200, body = bytes.from_string(`{n}`) }
  end
  return { status = 200, body = b"ok" }
end

http.serve(handle, { bind = "127.0.0.1:8080", context = { counter = Counter() } })?

Every request that counts goes through the one Counter, so it is the place to keep a total, not the place for slow work. To count requests you don't need one at all: http.stats() (below) already does.

A count kept in context itself would read 1 on every request, because each request starts from the value you passed. context is for what the handler needs to find: partitions, settings, a table of routes.

Run it, then in another terminal:

$ curl 127.0.0.1:8080/health
ok

#Check a server in CI without starting it

Some of what http.serve refuses is only known when the program reaches the call: a handler picked out of a table, a function in context that reaches a let, module state the program wrote first. Driving the handler through a stub in a test never reaches that call, so a test suite can pass for a server that will not start. --serve-check runs the program for real up to its first http.serve, applies every check the server makes before it starts, and then exits with status 0 instead of opening the port:

import http

fn handle(req)
  return { status = 200, body = b"ok" }
end

print("routes loaded")
http.serve(handle, { bind = "127.0.0.1:8080" })?
print("server stopped")
$ scua --allow-serve=127.0.0.1:8080 --serve-check server.scua
routes loaded
scua: --serve-check: http.serve would serve `handle` on 127.0.0.1:8080; every startup check passed, and nothing was bound

A refusal is reported exactly as it is without the flag, with the same status: 5, as for any fault. Here the handler is picked out of a table, so the compiler cannot follow it, and the refusal comes at the call:

import http
import bytes

let greeting = "hi"
const routes = { home = fn(req) return { status = 200, body = bytes.from_string(greeting) } end }
http.serve(routes.home, { bind = "127.0.0.1:8080" })?
$ scua --allow-serve=127.0.0.1:8080 --serve-check refused.scua
scua: refused.scua:6: the handler you pass to `http.serve` reaches 1 variable of the main script. A request runs in a partition of its own, on any core, so it can't reach it:
  - `greeting` (line 4) is read and never changed: make it a `const`

A program that finishes, or calls sys.exit(0), without ever reaching http.serve exits 8 and says that no server was started, so a CI step pointed at the wrong file, or at a server started only behind a flag, cannot pass by accident. So does one that carries on past an Error that http.serve handed back instead of starting.

Give the run the same grants and flags as the real one, since the checks read them: the bind must be inside --allow-serve, --fast and --io=blocking are refused as they would be. The program's startup code really runs, so anything it does before the call (reading config, spawning partitions) happens. What it cannot tell you is whether the port is free on the machine that will serve, because it never opens it.

#The request and the response

req is a record { method, path, headers, body }:

  • method and path are strings ("GET", "/health").
  • headers is a map. Header names are lowercased, so read req.headers["content-type"] even if the client sent Content-Type.
  • body is bytes — the raw request payload.

The response you return is a record { status, headers, body }, and every field has a default: { status = 200, headers = {}, body = b"" }. Return as little or as much as you need. Ok(response) works too, so a handler that uses ? can end with return Ok({ status = 200, body = … }), and a proxy can return what http.request gave it.

The server adds content-length, connection and a date header to every response (set your own date and it is used instead). A connection stays open for the next request unless the client asks to close it; a client speaking HTTP/1.0 gets its connection closed after the response unless it asks with Connection: keep-alive.

A HEAD request reaches your handler with method set to "HEAD". Answer it as you would the GET: the server sends the status and headers, with content-length set to the length of the body you returned, and leaves the body out. A handler that returns an empty body for HEAD sends content-length: 0.

#Bodies are bytes

The response body is bytes, not a string. Write it with a b"..." byte-string literal, or convert a string you already have with bytes.from_string:

return { status = 200, body = b"ok" }                      -- a byte literal
return { status = 200, body = bytes.from_string(message) } -- a string you have

A string body is a mistake the runtime catches: it becomes a 500, never a silently coerced response. Being explicit about bytes is what lets a handler serve an image or any binary payload with the same shape as text.

#Setting headers

Write the header map inline. A key is a name: bare when it's an identifier, quoted when it isn't — and header names usually aren't, because of the hyphen:

return { status = 200, headers = { "content-type" = "text/plain", xtag = "1" }, body = b"ok" }

Quote a key only when it needs it: { "content-type" = … } is right, { "xtag" = … } is an error telling you to drop the quotes. (Earlier versions had no way to write a hyphenated key in a literal at all, so older code builds the map by index-assignment; that still works, it's just no longer necessary.)

fn handle(req)
  if req.path == "/echo" then
    return {
      status = 200,
      headers = { "content-type" = "application/octet-stream" },
      body = req.body,
    }
  end
  return { status = 404, body = b"not found" }
end

A header value is a string. To send the same header more than once, give it an array of strings: each item goes out as a header line of its own. That is how one response sets several cookies, which browsers need as separate set-cookie lines rather than one line joined with commas:

fn login(req)
  return {
    status = 200,
    headers = {
      "content-type" = "text/plain",
      "set-cookie" = ["session=abc123; Path=/; HttpOnly", "theme=dark; Path=/"],
    },
    body = b"signed in",
  }
end

The response carries both, in the order you wrote them:

set-cookie: session=abc123; Path=/; HttpOnly
set-cookie: theme=dark; Path=/

Every item is checked as a single value is, and an empty array sends no line for that name. Any other value is a mistake the server catches, whether it is a number, a table or an array holding something that is not a string. The response becomes a 500, and the log line names the header and what it held, rather than the header being left out:

scua: serve.scua: http.serve GET /stats answered 500: response header `x-count` is an int — a header value must be a string, or an array of strings to send the header once for each

Convert a number with tostring (or put it in a string with backticks) before you set it. A few headers belong to the server and are refused in any form: content-length, connection and transfer-encoding. So is a value holding a carriage return or newline, and two names that differ only in case. A header name is a string too: a key of any other kind, such as h[1] = "x", is a 500 that says what kind of key it was.

#404s, 500s, and --serve-debug

A 404 is just a response you return, like any other status — the server answered, it simply said "not found".

A 500 is what happens when your handler can't answer: an uncaught fault, or a returned Error, becomes an HTTP 500. The fault text is withheld from clients, so a stack detail or an internal message never leaks to whoever is calling. The client gets a small JSON body with a reference instead:

HTTP/1.1 500 Internal Server Error
content-type: application/json

{"error":"internal server error","ref":"r-59fc3c43e28fa4be"}

The reference is random and says nothing about what went wrong, but the server's log line for that request names the same one, so a user (or a model calling your tool) can quote it and you can find the line. A 500 your handler returns itself is sent as you wrote it. When you're developing locally and want the fault text in the 500 body instead, pass --serve-debug:

scua --allow-serve=127.0.0.1:8080 --serve-debug serve.scua

Leave it off in anything real.

With or without the flag, every 500 the server answers because the handler faulted or returned an Error writes one line to stderr, with the request's method and path, the reference its body gave and the located message, so the operator sees what the client doesn't:

scua: serve.scua:12: http.serve GET /orders/7 answered 500 (ref r-59fc3c43e28fa4be): fields only exist on tables and records, not on nil values
scua: serve.scua: http.serve GET /orders/9 answered 500 (ref r-7fa2a1dc853e08e6): the handler returned Error(no such order)

A run of bad requests can't flood the log: at most 20 lines are written at once, then one a second, with a count of the rest. Returned Errors and faults are limited separately, so one can't crowd the other out. Faults a running program survives has the details.

A task you spawn beside the server, such as a poller or a cleanup job, can fault without stopping the server. The task ends, its fault is reported the same way, and requests keep being answered.

#Slow clients and idle connections

The server reads requests and writes responses without waiting on any one client, so a client that sends slowly, or stops reading a large response, holds up nobody else. What it does hold is a connection, so each stage has a time limit. All of these are flags, and the defaults suit running behind a proxy or load balancer:

Flag Default What it limits
--serve-header-timeout 10 s The request line and headers must arrive within this of their first byte. A connection that sends nothing at all is closed after the same time.
--serve-body-timeout 30 s The body must arrive within this of the end of the headers, and after its first 10 seconds it must keep coming at 1 KiB a second or more on average.
--serve-timeout 30 s The whole request, counted from its first byte: reading it and running the handler. A handler still running at the end gets a 503.
--serve-write-timeout 30 s A response the client stops taking for this long is given up on and the connection closed.
--serve-idle-timeout 75 s A kept-alive connection with nothing to do is closed. Longer than the 60 seconds most load balancers use, so the balancer closes first.

Each takes a duration such as 10s, 500ms or 2m; a bare number is milliseconds, and 0 turns that limit off. A request that runs out of time while it is still arriving gets a 408 Request Timeout and the connection closes.

#How many connections

The server holds at most 10,000 connections at once, or fewer when the process's open-file limit is lower (it raises its own soft limit as far as the system allows, and the startup line says which limit it used). Past that it stops accepting for a moment and new connections wait in the operating system's queue until one closes; --serve-max-conns=N sets the number. Open connections also count against the process's limit on watched sockets, --net-max-registrations=N (default 16384), which parked net and http calls share: the server holds no more connections than that, and when parked calls have taken the rest it pauses accepting in the same way and says so. If the process runs out of file descriptors anyway, the server says so on stderr, at most once a second, and pauses accepting rather than spinning.

Request bytes that have arrived but don't yet make a whole request are buffered, and the total across every connection is capped at 64 MiB (or an eighth of the container's memory limit, when it has one); --serve-max-buffered=BYTES changes it. Past the cap, the connections making the least progress are closed first, so a client trickling bytes is dropped before an upload that is arriving at speed.

#Stopping a server

Ctrl-C or SIGTERM stops every server in the program the same way:

  1. With --serve-drain-delay=DUR (default 0), the server keeps accepting and answering for that long first. Behind a load balancer, set this to how long the balancer takes to stop sending you traffic.
  2. It stops accepting. Idle connections close, and each response from now on carries Connection: close.
  3. Requests already in flight get --serve-grace (default 25 s) to finish. Whatever is still in flight after that is abandoned and its connection closed.
  4. Each http.serve returns Ok({ drained, abandoned }): how many requests it answered after the stop began, and how many it had to abandon. The program carries on from there, so a flush or a save after the call runs, and the program exits when its main body ends.
import http

fn handle(req)
  return { status = 200, body = b"ok" }
end

match http.serve(handle, { bind = "127.0.0.1:8080" })
  Ok(done) -> print(`stopped after answering {done.drained} more requests`)
  Error(e) -> print(`could not start the server: {e}`)
end
-- save anything that needs saving here

On stderr it looks like this:

scua: stopping: waiting for 3 requests (Ctrl-C again to quit now)
scua: stopped cleanly: 3 requests answered while stopping

A second Ctrl-C or SIGTERM ends the process at once. The exit status is 0 after a clean stop and 75 if any request was abandoned (a fault or a status your program chose with sys.exit still wins). Once a stop has begun, a new http.serve returns an Error instead of starting, so a program that restarts its server in a loop still stops. SIGHUP, which a closed terminal sends, does not stop a server: it writes one line saying it was ignored and keeps serving.

With the defaults the stop takes at most 25 seconds, which fits inside the 30 seconds Kubernetes and most process managers allow before they force the issue. Keep the drain delay plus the grace under whatever yours allows.

#A health check for the load balancer

Give http.serve a health path and the server answers it itself, without running your handler, so a check still gets through while every handler is busy:

import http

fn handle(req)
  return { status = 200, body = b"ok" }
end

match http.serve(handle, { bind = "127.0.0.1:8080", health = "/healthz" })
  Ok(done) -> print("stopped")
  Error(e) -> print(`could not start the server: {e}`)
end
  • A GET or HEAD of exactly /healthz, with no query string and no body, gets 200 and ok while the server is serving. Anything else on that path goes to your handler as usual.
  • It gets 503 once a stop has begun, so the balancer stops sending traffic during the drain delay.
  • Add health_sheds = true to also answer 503 for a second or so after the server has turned requests away because it was overloaded.
  • The path must start with / and have no query string. If your handler has its own route on the same path, http.serve refuses to start and says so.

#What a running server tells you

  • --serve-stats=DUR writes one line every DUR (say --serve-stats=10s) with the requests answered in that time by status class, the median and 99th-percentile latency, connections against the cap, and anything the server decided itself, such as timeouts or shed requests. (Every-N-requests memory figures are --serve-stats-every=N.)
  • --serve-log writes one JSON line per request to stderr: method, path, status, cause (what the server decided, or null), duration_ms, bytes and request_id. A request that arrives with an X-Request-Id header has it passed through; otherwise one is made up.
  • Under --output=json the startup line, the stats and the stop's lines are JSON too, one object per line.
  • http.stats() gives your program the same numbers, counted since the process started: requests, status ("2xx" and so on), causes, a latency histogram, connections (open, max, accepted), accept_errors and the buffered bytes against their limit. The counters only ever go up, which is the shape a metrics scraper expects:
import http
import bytes
import json

fn handle(req)
  if req.path == "/metrics" then
    return { status = 200, body = bytes.from_string(json.encode(http.stats())) }
  end
  return { status = 200, body = b"ok" }
end

#Memory limits while serving

--max-mem bounds each request the way it bounds any partition. A request whose handler goes over it gets a 500, written to stderr like any other failed request, and nothing else changes: the server goes on answering. (An actor that reaches the limit still ends the run, as it always has.)

#Raw TCP with net.listen

When you need to speak a protocol http doesn't — a line-based service, a custom wire format — the same serve grant gives you a raw TCP listener through the net module. net.listen and net.accept are gated exactly like http.serve: both need --allow-serve.

import net

match net.listen("127.0.0.1", 9000)
  Ok(listener) -> do
    match net.accept(listener)
      Ok(conn) -> do
        net.write(conn, "hello\n")
        net.close(conn)
      end
      Error(why) -> print(`accept failed: {why}`)
    end
    net.close(listener)
  end
  Error(why) -> print(`could not listen: {why}`)
end
  • net.listen(addr, port) → Ok(listener) or Error.
  • net.accept(listener) → Ok(connection) or Error — waits for the next client.
  • Read and write a connection with net.read / net.write, exactly like a net.dial connection (see Fetch data over HTTP).
  • net.close(x) closes either a connection or a listener.

Reach for http.serve for ordinary request/response work; reach for net.listen when you genuinely need the bytes.

See the runnable examples/serve.scua for the server above.

  • A partition cannot be snapshotted while it is serving — the attempt is refused, naming the listener, rather than half-saving a running server. An actor handler that calls out (http.get, fs.read) is the case that can be snapshotted mid-request; see Write handlers that survive snapshots for the one habit that keeps it correct.
  • To fan a handler's own outbound work out in parallel, see Run many things at once.