Running a server¶
The target is module:attribute — here, the app in main.py — imported from
the working directory. With no attribute, app is assumed. The same command is
installed as oxb, and runs as python -m oxbrook.
From Python, the same server with the same options:
Every option can also be passed to app.build_server(...), which prepares a
server without starting it — that is what the test client
uses.
| command line | app.run |
default | what it does |
|---|---|---|---|
--host |
host |
127.0.0.1 |
interface to bind |
--port |
port |
8000 |
port to bind |
--workers |
workers |
detected | Python worker loops |
--max-concurrency |
max_concurrency |
1024 | requests per worker, queued plus in-flight |
--max-connections |
max_connections |
2048 | sockets held open |
--max-body |
max_body |
16 MiB | largest request body, in bytes |
--max-message |
max_message |
16 MiB | largest WebSocket message, in bytes |
--request-timeout |
request_timeout |
30.0 | seconds to a handler's first response |
--shutdown-grace |
shutdown_grace |
10.0 | seconds a stop waits for in-flight requests |
The command also takes --access-log, to log every request, and
--log-level, which sets the level of Oxbrook's own loggers.
Reloading during development¶
Saving a Python file under the working directory restarts the server. Each restart is a new process, not modules re-imported in place: the Rust extension cannot be reloaded inside a running interpreter, and a fresh process runs the lifespans again, so after a reload the app is in exactly the state a deploy would leave it in.
--reload-dir DIRwatches another directory, and can be repeated.--reload-include '*.html'restarts for other files too.- Hidden directories,
__pycache__,node_modules,venv,target,site,distandbuildare never watched.
A syntax error stops the server, not the watcher: the error is printed, and the
next save starts it again. A server being replaced gets one second to finish its
requests rather than the usual ten, so an open SSE stream in a browser does not
hold every restart; --shutdown-grace overrides that.
--reload is for development. It polls files for changes and runs a second
process to do it.
Inspecting an app¶
oxbrook routes main:app # every route, including the built-in ones
oxbrook routes main:app --json
oxbrook openapi main:app -o openapi.json
routes marks tools, WebSockets, streaming bodies, forms and router middleware,
and lists /openapi.json, /docs and /mcp as the server would serve them.
openapi writes the document without starting a server, for generating clients
in CI.
For an app built by a function, pass --factory: oxbrook run main:create_app
--factory. A target that cannot be loaded exits with status 2 and says why;
an exception raised while importing the app shows its traceback.
Worker loops¶
Each worker is one OS thread running one asyncio loop. They share the process, which is what lets a topic reach subscribers on all of them.
The default is measured, not guessed:
- On a GIL build, always one. One loop was best or tied at every handler cost in the sweep; extra loops only cost throughput.
- On a free-threaded build, one is never best. Even a handler that does nothing gains from more loops, and a handler doing 500µs of work gains 3.4x. Throughput peaks around the performance-core count and falls off once the efficiency cores are oversubscribed.
So the default is "how many cores can actually run Python in parallel", which
is not os.cpu_count(): that counts efficiency cores, and inside a container it
reports the host's cores rather than the cgroup quota — which would start
dozens of loops for a two-CPU limit. Oxbrook probes cgroup quotas, CPU affinity
and performance-core counts, takes the most constrained answer, and caps it at
8.
The cap is a guard against an absurd probe result, not a measured ceiling. Raise it explicitly on a large homogeneous machine with CPU-heavy handlers:
More loops is for CPU, not for I/O
Awaiting I/O does not need more loops. An await yields the loop, so one
loop holds thousands of them. What needs more loops is CPU time spent
inside the handler.
Backpressure¶
Each worker accepts at most max_concurrency requests at once, counting both
those queued and those already running. Past that the request tries another
worker, and if every worker is full the server answers 503 with
Retry-After: 1.
Counting in-flight requests is the part that matters. The drain callback empties the queue into asyncio tasks immediately, so a handler that awaits I/O leaves the queue near empty while thousands of requests pile up inside the loop. Bounding the queue alone would look like backpressure and protect nothing.
Lower it for slow handlers, where a deep backlog only adds latency before an inevitable client timeout. Raise it to absorb larger bursts of fast requests.
max_connections is a separate limit, because an idle keep-alive connection
costs a file descriptor without ever reaching a worker. At that limit the
server stops accepting rather than refusing, so the wait lands in the OS
backlog where a client's own connect timeout governs it.
In front of it¶
Oxbrook speaks HTTP/1.1 with no TLS and no HTTP/2. Put a terminating proxy in front of it — nginx, Caddy, a cloud load balancer — and let that handle TLS, HTTP/2 and whatever else the edge needs.
WebSocket upgrades are accepted from the app's own origin, which is recognised
through Host or X-Forwarded-Host. A proxy that rewrites Host without
setting X-Forwarded-Host makes every browser socket look cross-origin and
refused with 403; forward the original host, or list the public origin in
websocket_origins.
If the proxy also adds CORS headers, configure CORS in one place only: two
Access-Control-Allow-Origin headers on one response make a browser reject it.
A proxy that buffers request bodies defeats a streaming
upload; turn request buffering off for those
routes (in nginx, proxy_request_buffering off).
Containers¶
The repository ships a multi-stage Dockerfile that installs free-threaded
3.14 with uv, builds the wheel, and installs it into a slim runtime image.
The official Python images carry no free-threaded interpreter, so installing it
explicitly keeps the container and the development machine on the same
interpreter.
Bind to 0.0.0.0 inside a container. Anything listening only on loopback is
unreachable from outside it.
Use the exec form, as above, so the server is the process that receives the container's stop signal. Wrapped in a shell, the shell receives it instead.
Benchmark on the Docker network, not through a published port
Traffic from macOS into Docker Desktop's VM through -p measured 3.3x
slower than native, and the cost is the port forwarding, not the
container: with the load generator in a second container on the same
network, the same image measured faster than native. See
performance.
Shutdown¶
Ctrl-C (SIGINT) and SIGTERM both stop the server gracefully: it stops
accepting, waits up to shutdown_grace for in-flight requests, runs the
lifespan teardown, then exits with status 0. Streams and sockets are closed.
SIGTERM is what docker stop, Kubernetes and systemd send, so a deploy drains
requests rather than cutting them off. Set the orchestrator's own grace period —
terminationGracePeriodSeconds, docker stop --time — longer than
shutdown_grace, or it kills the process before the drain ends.