Runtime
Application
App extends Router with the protocol entrypoint, error surface, shutdown signaling, and shutdown-aware task tracking. The CLI or Server constructs the instance; bootstrap(app, span) receives that object. App() requires a running event loop — do not construct it at import time.
On this page: lifecycle and bootstrap, exception handling, shutdown-aware tasks, and Server. Process settings are in Configuration. Static files are in Static 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, AssetManifest, Context, Span, StaticAssets, UrlPath, Writer HOME = UrlPath("/")STATIC = UrlPath("/static")ASSETS = AssetManifest(Path("static"), url_prefix=STATIC) async def home(c: Context, w: Writer) -> None: ... async def bootstrap(app: App, span: Span) -> AsyncIterator[None]: span.attr("phase", "startup") app.get(HOME, home) assets = StaticAssets(ASSETS) assets.register(app) yield span.attr("phase", "shutdown")Point stario serve main:bootstrap or stario watch main:bootstrap at that function. Use span.attr, span.attrs, span.event, and span.step during bootstrap while wiring routes or opening resources.
After bootstrap, each inbound message runs await app(c, w): request span, route resolution, error handling, and writer.end() on success (or w.abort() if 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, App resolves an on_error handler by walking the exception MRO; the most specific registered type wins. Handlers must be async def.
HttpException maps to 4xx/5xx response bodies via the default handler (responses.text). Use responses.* for intentional 2xx bodies. For redirects (3xx), raise RedirectException so location is distinct from a body string. Defaults: HttpException → responses.text, RedirectException → responses.redirect, ClientDisconnected → Writer.abort() (no response body).
RedirectException validates location when the default handler calls responses.redirect. 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 ends the writer; it cannot send a late 5xx for that exchange.
from stario import App, Context, Span, Writerimport stario.responses as responses class OrderNotFound(Exception): pass async def order_not_found(c: Context, w: Writer, exc: OrderNotFound) -> None: responses.text(w, "Unknown order", status=404) async def bootstrap(app: App, span: Span): app.on_error(OrderNotFound, 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 the shared shutdown future completes (app.shutdown). await app.shutdown blocks until draining begins — use it inside tasks started with app.create_task when you need cooperative teardown without a Writer. It only works when the app runs under a lifecycle that attaches the shutdown future (CLI server, TestClient, or aload_app).
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()
Concrete app type: everything on Router plus errors and shutdown-aware tasks.
Uncaught exceptions become HTTP responses only before headers are sent; after that, telemetry still records the failure. Use create_task for work tied to a running server so graceful shutdown can observe it.
async App.__call__(c, w)
Protocol entrypoint: open a span, resolve routes, handle errors, and finish started responses.
c: Request context (app,req,span,route,state).w: Response writer for this message on the connection.
Trailing slashes (except /) get 308 to a canonical path (leading /, no trailing slash) before find_handler runs. Wrong method on a matching path yields 405.
Uncaught exceptions while headers are not sent: HttpException → responses.text, RedirectException → responses.redirect, ClientDisconnected → w.abort() (no body); anything else falls back to 500 unless on_error handled it. If a registered error handler raises, the request span is marked failed and 500 is sent when the handler did not start or complete the writer. Handlers must explicitly send a response on the success path.
App.on_error(exc_type, handler)
Register a handler for uncaught exceptions of type exc_type (subclasses use MRO; most specific wins).
exc_type: Exception class to match.handler: Async callable receiving(context, writer, exc).
Only runs while the writer has not started (w.started is false); after headers are sent, failures use w.abort() in the finally block. HttpException, RedirectException, and ClientDisconnected are registered by default.
App.create_task(coro, *, loop=None, name=None)
Schedule a coroutine on the running loop and retain the task until it completes.
The HTTP protocol schedules each request's App.__call__ through this method so graceful shutdown can await in-flight handlers. 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.
The new asyncio.Task.
StarioError: If no event loop is running (call from async request or app code only).
App.signal_shutdown()
Complete the shutdown future if still pending.
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.
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.
Typical embedding:
from stario.http.config import ServerConfigfrom stario.http.server import Serverfrom stario.telemetry.json import JsonTracer with JsonTracer() as tracer: Server(bootstrap, tracer, config=ServerConfig()).run()run() blocks and selects the event loop from config.event_loop (asyncio or uvloop). The tracer must already be entered by the caller (see stario.cli.runtime).
serve() is the async entry when you already have a running loop. One Server instance supports one run() / serve() — create a new instance to restart.
server.event_loop on the startup span reflects ServerConfig.event_loop (asyncio or uvloop).
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 (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 (short-circuits the remaining graceful wait; 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).
Startup span attributes record server.shutdown.open_connections, 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)
Binds a listener, runs bootstrap on a fresh app, serves until SIGINT/SIGTERM, then drains.
One instance, one serve() / run() — create a new Server to restart.
Typical embedding::
with tracer: Server(bootstrap, tracer, config=config).run()
Server.run()
Block until shutdown; picks the event loop from config.event_loop.
The tracer must already be entered by the caller (see cli/runtime.py).
async Server.serve()
Run until SIGINT/SIGTERM (or fatal error); requires a running event loop.
The tracer must already be entered by the caller (see cli/runtime.py).