Browse the docs · Apsi 2.4.4
Writing servers with Apsi (use web)
use web
let app = server(name="my-api")
@app.get("/hello/{name}")
fn hello(req) = {"message": "Hello " + req.params["name"]}
@app.post("/energy")
fn energy(req):
let b = req.json() # {"mass": {"value": 2, "unit": "kg"}, ...}
return {"energy": 0.5 * b["mass"] * b["speed"] ** 2} # units are checked on every request
app.run(host="0.0.0.0", port=8080) # run with --allow-net
Architecture
| Part | How it works |
|---|---|
| Accepting connections | one asyncio event loop holds tens of thousands of keep-alive clients with little memory |
| Processing | handlers run on a worker thread pool (workers=), so numpy/AI work runs truly in parallel and never blocks accepting requests. A route that proves fast (under 0.5 ms, measured by CPU time so a busy server does not mislead it) runs directly on the event loop, without a thread hand-off; a route that is slow or blocks on I/O stays on the workers |
| Several cores | app.run(processes="auto") (one per CPU) or processes=N forks processes on the same port (SO_REUSEPORT, Linux and macOS) and the kernel spreads connections between them. Each process has its own memory: keep shared state in a database or memory.store() |
| Event loop | uvloop (libuv) when it is installed — pip install "apsi[web]" or pip install uvloop — otherwise asyncio's loop; APSI_UVLOOP=0 turns uvloop off. The start-up line names the loop in use |
| Per-request cost | request ids from a counter (no system call), the security headers merged in one step, routes without parameters found by a dictionary lookup, the header block validated by one regular expression, one timer per connection instead of one per request, and fast routes timed by the wall clock only |
| Shutdown | on SIGINT/SIGTERM the server stops accepting new connections and lets running requests finish (graceful shutdown) |
Measured — a synthetic benchmark (a route that returns a fixed 25-byte JSON object, keep-alive, the Go load
generator in benchmarks/web on the same machine):
| AMD EPYC 7763 GitHub Actions runner (4 vCPU), best of 3 rounds | 64 connections | 256 connections |
|---|---|---|
| Apsi, 1 process | 14,008–14,978 req/s | 13,671–14,830 req/s |
| Apsi, 1 process, uvloop | 15,493–16,863 req/s | 14,958–15,392 req/s |
Apsi, processes="auto" |
41,718–42,085 req/s | 44,042–44,269 req/s |
Node.js 22 bare http (1 process) |
19,872–22,198 req/s | 22,862–25,155 req/s |
Per process, the work per request went down by about 16 % in this release (measured on a 2-core VM). With one process
per core Apsi answers about 1.8× the requests of a single Node.js process; one Apsi process answers about two thirds
of Node's (Node.js itself needs its cluster module to use more than one core). With 1,000 open connections no
request fails (15,454 req/s); while 64 connections keep a blocking 10 ms route busy, a fast route on the same process
still answers 9,888 requests/s with a p99 of 7.9 ms (the slow route runs on worker threads, the fast one on the event
loop).
What these numbers are and are not. This is a synthetic test of the server's fixed cost per request: no database,
no request body to parse, no TLS, no real network (loopback), and the load generator shares the cores with the
server. It shows how much work the server itself adds. It does not predict a real application, where a request
usually spends milliseconds in the database, in other services or in the handler's own work; there, the per-request
overhead measured here (tens of microseconds) is a small part of the total, and the worker threads (which run
handlers in parallel) and processes="auto" matter more. Measure your own routes with tools/loadtest.py or
benchmarks/web/.
python tools/loadtest.py http://127.0.0.1:8080/health --requests 20000 --concurrency 500
Security (on by default)
| Threat | Protection |
|---|---|
| Slowloris (slow headers) | header_timeout (10 s), body_timeout (30 s), keepalive_timeout (15 s) |
| Oversized body/headers | max_body (1 MB) → 413; headers over 16 KB or over 100 lines → 431 |
| Request smuggling | duplicate Content-Length, chunked requests and obs-fold headers are rejected |
| Header injection | headers containing line breaks are dropped |
| Brute force / DoS | app.rate_limit(rate, per, burst) — a token bucket per IP → 429 with Retry-After; connection limit (max_connections) |
| Stuck handlers | handler_timeout → 504 |
| Path traversal | app.static() checks the real path, so nothing outside the folder can be served (including %2e%2e) |
| Information leaks | clients never see a stack trace (only a request id); details go to the server log |
| Clickjacking / MIME sniffing | X-Frame-Options: DENY, X-Content-Type-Options: nosniff, CSP, Referrer-Policy, Permissions-Policy, HSTS with TLS |
| Authentication | jwt_sign / jwt_verify (HS256, alg=none refused, exp/nbf checked), require_jwt, require_api_key (constant-time compare) |
| Passwords | use crypto → password_hash (scrypt, or PBKDF2 with 600k rounds) |
| TLS | app.run(tls_cert="cert.pem", tls_key="key.pem") — TLS 1.2 or newer |
| Program permissions | opening a port needs --allow-net; serving files needs the fs permission |
Socket-level tests for all of this live in tests/test_web.py.
API
let app = server(name="api", max_body=1048576, workers=64, max_connections=10000, log=true,
handler_timeout=60[s], header_timeout=10[s], body_timeout=30[s],
keepalive_timeout=15[s], security_headers=true)
app.get(path, handler) / @app.get(path) # also post, put, patch, delete, route(method, path)
app.before(fn(req) => none | response) # middleware before the handler
app.after(fn(req, resp) => ...) # middleware after the handler
app.rate_limit(100, per=1[s], burst=200)
app.cors(["https://example.com"]) # or "*"
app.static("/assets", "public")
app.health() # GET /health
app.expose_metrics() # GET /metrics: requests, errors, status codes, p50/p90/p99, req/s
app.stats() # the same numbers inside your program
app.handle("GET", "/path", body, headers) # call a route directly without the network (for tests)
app.run(host, port, tls_cert, tls_key, processes, workers)
app.run_background(port=0) # run on a separate thread; returns record(port, url, stop)
Path patterns: /users/{id:int}, /files/{rest:path}, /x/{v:float}, /name/{s}
Request: req.method, req.path, req.params, req.query, req.headers, req.header(name),
req.body (bytes), req.text, req.json(), req.client, req.id, req.user (set by require_jwt)
Handler return values: dict / list / tensor / quantity → JSON; Str → text;
response(body, status, headers, type); redirect(url); abort(status, message) to stop with an error; none → 204
Errors: a UnitError/ShapeError/TypeError caused by the client's data → 400; any other error → 500
WebSocket
use web
let app = web.server()
@app.websocket("/echo")
fn echo(ws, msg): # message mode: called once per message; no thread waits per connection,
ws.send(msg) # so tens of thousands of idle connections cost almost nothing
@app.websocket("/chat", origins=["https://example.com"], max_message=1_000_000, ping=20)
fn chat(ws): # session mode: one conversation per connection
ws.join("lobby")
for msg in ws: # ends when the client closes
app.room("lobby").send({"from": ws.id, "text": msg}) # dicts and lists are sent as JSON
app.run(port=8080)
app.websocket(path, handler, origins=, max_message=, ping=, on_open=, on_close=, subprotocols=)— the handler takes(ws, msg)for message mode or(ws)for session mode.ws.send(text | bytes | dict | list),ws.receive(timeout=),ws.close(code, reason),ws.join(room),ws.leave(room),ws.receive_json(),ws.id,ws.request(the upgrade request: path, headers, query).app.room(name).send(msg, exclude=ws)broadcasts to every member;len(app.room(name))counts them.- Messages of one connection are handled in order; slow receivers get back-pressure instead of unbounded buffers.
- RFC 6455 in full: text and binary, fragmentation, ping/pong keep-alive (dead peers are dropped), the closing handshake, UTF-8 checking of text, client masking enforced, a size limit per message (16 MB by default) and Origin checking.
Client (needs --allow-net for the host):
let c = web.websocket_connect("ws://localhost:8080/echo")
c.send("hi")
print(c.receive())
c.close()
Measured on an AMD EPYC 7763 GitHub Actions runner (4 vCPU) with a Node.js 22 client: a round trip takes 225 µs; a burst of 20,000 messages is
echoed at 50,591 messages/s, in order; 1,000 concurrent connections all answered. Reproduce with
benchmarks/websocket/.
Talking to Next.js / JavaScript / Java
See INTEROP.md. Values with units always travel as {"value": 9.0, "unit": "J"}.
Found a mistake? Report it · Markdown source