Skip to content

Running a server

oxbrook run main:app

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:

app.run(host="0.0.0.0", port=8000)

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

oxbrook run main:app --reload

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 DIR watches another directory, and can be repeated.
  • --reload-include '*.html' restarts for other files too.
  • Hidden directories, __pycache__, node_modules, venv, target, site, dist and build are 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:

app.run(workers=16)

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.

app.run(max_concurrency=1024)   # the default, per worker loop

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.

make image     # build oxbrook:dev
make stack     # two nodes against one redis
make down      # stop everything

Bind to 0.0.0.0 inside a container. Anything listening only on loopback is unreachable from outside it.

CMD ["oxbrook", "run", "main:app", "--host", "0.0.0.0", "--port", "8000"]

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.