Apsi
Browse the docs · Apsi 1.10.0
You are reading the documentation of Apsi 1.10.0. The current version is Apsi 2.4.4 — what changed.

Docs 1.10.0 · 3 min read

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
Several cores on Linux, app.run(processes=N) forks N processes on the same port (SO_REUSEPORT) and the kernel spreads connections between them
Shutdown on SIGINT/SIGTERM the server stops accepting new connections and lets running requests finish (graceful shutdown)

Measured (2 vCPU machine, load generator on the same machine): 7,700 requests/s at 500 concurrent connections, p99 82 ms, 0 errors out of 20,000 requests.

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

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