The runtime layer runs your bootstrap on an App, serves connections, and drains work on shutdown.
App#
App extends Router with shutdown signaling and shutdown-aware task tracking; the protocol dispatches through its compiled route table. serve or Server constructs the instance; bootstrap(app, span) receives that object. App() requires a running event loop — do not construct it at import time.
Process settings are in Configuration. Static files are in Files and assets.
Application lifecycle#
Bootstrap must be an async generator with exactly one yield. Code before yield runs at startup; code after runs during teardown. The framework advances the generator with anext() so server failures in the scoped body do not enter teardown through generator .athrow().
Synchronous def bootstrap without yield, coroutine-only bootstrap, and multi-yield generators are rejected with StarioError.
from collections.abc import AsyncIteratorfrom pathlib import Path from stario import App, Assets, Context, Route, Span, Writer HOME = Route("GET /")ASSETS = Assets(Path("static"), "/static") async def home(c: Context, w: Writer) -> None: ... async def bootstrap(app: App, span: Span) -> AsyncIterator[None]: span.attr("phase", "startup") app.add(HOME, home) await ASSETS.attach(app) yield span.attr("phase", "shutdown")Pass that function to serve() — asyncio.run(serve(bootstrap)) in main.py, or uvloop.run(serve(bootstrap)). Use span.attr, span.attrs, span.event, and span.step during bootstrap while wiring routes or opening resources.
After bootstrap, each inbound message runs the handler its route resolved to: request span, route resolution, error handling, and w.end() on success, or w.abort() when headers were sent and an error occurred. Handlers use c and w, not the bootstrap span. Shutdown timing is documented under Server — Shutdown sequence. For file-watched reload during development, see Hot reload.
Exception handling#
When a handler or middleware lets an exception propagate, two of Stario's own exceptions answer themselves before the response starts: RedirectException calls w.redirect, and RequestBodyError calls w.text with its 413 or 408 status. ClientDisconnected aborts with no response body.
Your own exception types map through catch_errors middleware — see Error handling. Use the w.* methods for intentional bodies in the handler.
RedirectException validates location when w.redirect runs. An unsafe URL records failure on the request span and falls back to 500.
ClientDisconnected is raised when the peer closes while the request body is still uploading. For long-lived responses, prefer c.closing or c.alive() over catching this exception in every path.
StarioError and StarioRuntime are not registered on App; if they propagate uncaught before headers are sent, the client receives 500.
Other errors become a response only while w.started is false. After the status line and headers are on the wire, the runtime records the failure on the request span and w.abort()s the writer; it cannot send a late 5xx for that exchange.
from stario import App, Context, Span, Writerfrom stario.http.middleware import catch_errors class OrderNotFound(Exception): pass async def order_not_found(c: Context, w: Writer, exc: BaseException) -> None: w.text("Unknown order", status=404) async def bootstrap(app: App, span: Span): app.use("/", catch_errors(OrderNotFound, respond=order_not_found)) yieldShutdown-aware tasks#
app.create_task(coro) schedules the coroutine and retains the resulting asyncio.Task until it completes. Server awaits that set during shutdown alongside open connections, sharing graceful_shutdown_timeout.
app.shutting_down is true once signal_shutdown() runs — from a signal handler, a worker thread, or TestClient. await app.shutdown attaches the calling loop on first use and completes when signal_shutdown() runs — use it inside tasks started with app.create_task when you need cooperative teardown without a Writer.
Bare asyncio.create_task is not tracked and may be orphaned on shutdown. Prefer app.create_task for bootstrap loops and request-adjacent background work.
await app.drain_tasks() waits until every create_task work item finishes (common in tests after the HTTP response returns). Do not call it from inside a tracked task or you risk deadlock.
class App()#
Route table plus task tracking for graceful shutdown.
Handlers are async def and must write a complete response. Uncaught exceptions are logged; if nothing was sent, the framework writes 500. A response already on the wire is not rewritten. Use catch_errors middleware or write error responses in handlers. Use create_task for work tied to a running server so drain can observe it.
shutdown is a per-loop Future for the calling loop. signal_shutdown() completes it on every attached loop so worker threads wake together. tasks is per-thread so drain only sees work on this loop.
App.create_task(coro, *, loop=None, name=None, eager_start=False)#
Schedule a coroutine on the running loop and retain the task until it completes.
The HTTP protocol schedules each request handler through this method so graceful shutdown can await in-flight work. App code can use the same API for background work; both share tasks until shutdown drain.
coro: Coroutine to run.loop: Optional loop to schedule on when the caller already has it.name: Optional task name for debuggers.eager_start: Run immediately until the first suspension.
The new asyncio.Task.
StarioError: If no event loop is running (call from async request or app code only).
App.signal_shutdown()#
Complete shutdown on every attached loop.
App.attach_loop(loop=None)#
Register loop (default: running loop) so shutdown reaches it.
async App.drain_tasks()#
Await until every task created with create_task has finished (including nested scheduling).
Useful in tests to wait for background work after the HTTP response has been sent. Do not call from inside a coroutine that is itself tracked in create_task or you risk deadlock.
serve#
serve(bootstrap, *, config=None, tracer=None) is the async in-process entry. It builds a Server and serves until SIGINT, SIGTERM, or app.signal_shutdown(). It does not start an event loop — wrap it with the implementation you want:
import asynciofrom stario import serve if __name__ == "__main__": asyncio.run(serve(bootstrap))Use uvloop.run(serve(bootstrap)) for uvloop. On an existing loop, await serve(bootstrap).
When config is omitted, serve() calls ServerConfig.from_env() and the STARIO_* variables apply; a ServerConfig you pass is used as-is and the environment is not read. When tracer is omitted, serve() reads STARIO_TRACER via tracer_from_env() and scopes that tracer to the call. A tracer you pass in must already be entered; serve() does not close it. Server always takes an open tracer and a ServerConfig.
async serve(bootstrap, *, config=None, tracer=None)#
Serve HTTP until shutdown on the running event loop.
Start a loop with the implementation you want::
asyncio.run(serve(bootstrap)) uvloop.run(serve(bootstrap))
When config= is omitted, listen settings come from the STARIO_* environment variables (see ServerConfig.from_env). When it is passed, it is used as-is — environment variables are not merged in.
A tracer you pass in must already be entered and is not closed here. When omitted, STARIO_TRACER is read via tracer_from_env() and the tracer is scoped to this call.
Server#
Server is the asyncio listener around an App instance. It creates the app, runs bootstrap through bootstrap_run, binds TCP or Unix, handles signals, serves until SIGINT/SIGTERM, then drains connections and tasks before socket teardown.
stario.serve is the usual embedding path. Server.serve() is the async method it awaits. Enter a tracer you pass in; the server does not close it.
import asyncio from stario import ServerConfigfrom stario.http.server import Server with tracer: asyncio.run(Server(bootstrap, tracer, config=ServerConfig(port=9000)).serve())One Server instance supports one serve() — create a new instance to restart.
server.event_loop on the startup span is the loop that is actually running (asyncio or uvloop).
With config.threads > 1 (STARIO_THREADS), Server runs that many SO_REUSEPORT listeners — one event loop per OS thread, all sharing the one App. >1 requires free-threaded Python 3.14t, or STARIO_THREADS_ALLOW_GIL=1 as an opt-in on a GIL build. config.pin_threads (STARIO_PIN_THREADS) pins each worker to a CPU where the OS allows. signal_shutdown() wakes every attached loop, so all workers drain together.
Bootstrap contract#
bootstrap_run(bootstrap, app, span) enters startup (anext on the user generator), yields for the server scope, then advances the generator once more for teardown. If startup raises before yield, post-yield teardown does not run. See stario.http.bootstrap for the exact error messages when yield is missing or duplicated.
Shutdown sequence#
On SIGINT or SIGTERM, Server stops accepting new connections and drains work before bootstrap teardown — the code after yield. The window is graceful_shutdown_timeout from Configuration, default five seconds. Total wall time can exceed that value by up to about one second during force-close.
Stop accepting — the listener closes; in-flight connections remain.
Close idle keep-alive sockets — connections with no active handler close immediately, so drain does not wait on idle peers.
Graceful wait — until open connections and
app.create_taskwork finish, or the timeout elapses. A second SIGINT/SIGTERM during this phase sets urgent drain mode: it short-circuits the remaining graceful wait, while force-close and task cancellation still run.Force-close — remaining transports are closed in a loop capped at
min(graceful_shutdown_timeout, 1s).Cancel stragglers — pending
app.create_taskcoroutines are cancelled.Bootstrap teardown — the generator after
yieldruns; close pools, flush clients, and so on.
The shutdown span records server.shutdown.open_connections, server.shutdown.urgent, server.shutdown.idle_closed, server.shutdown.force_closed, server.shutdown.stale_connections, and server.shutdown.cancelled_tasks, then a server.shutdown.closed event.
Long-lived handlers should use c.alive() or check c.closing / app.shutting_down so SSE and relay loops exit during phase 3. Background work started with app.create_task is awaited in the same drain window.
bootstrap_run(bootstrap, app, span)#
Bootstrap startup on enter, teardown on exit.
Uses anext() on the user generator — not async with on it — so server failures in the scoped body never enter bootstrap teardown via .athrow().
class Server(bootstrap, tracer, *, config=None, make_protocol=None)#
Binds a listener, runs bootstrap on a fresh app, serves until SIGINT/SIGTERM, then drains.
One instance, one serve() — create a new Server to restart.
Typical embedding is await stario.serve(bootstrap) or await Server(bootstrap, tracer, config=cfg).serve() inside asyncio.run.
STARIO_THREADS>1 is N SO_REUSEPORT listeners (thread 0 included). Without kernel support the server stays single-threaded.
async Server.run_reuseport_worker(worker)#
Extra thread: bind SO_REUSEPORT, serve until shutdown, then drain.
async Server.serve()#
Run until SIGINT/SIGTERM (or fatal error); requires a running event loop.
The tracer must already be entered. This method does not enter or exit it.