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.

python
from collections.abc import AsyncIterator
from 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: HttpExceptionresponses.text, RedirectExceptionresponses.redirect, ClientDisconnectedWriter.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.

python
from stario import App, Context, Span, Writer
import 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)
    yield

Shutdown-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: HttpExceptionresponses.text, RedirectExceptionresponses.redirect, ClientDisconnectedw.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:

python
from stario.http.config import ServerConfig
from stario.http.server import Server
from 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.

  1. Stop accepting — the listener closes; in-flight connections remain.

  2. Close idle keep-alive sockets — connections with no active handler close immediately so drain does not wait on idle peers.

  3. Graceful wait — until open connections and app.create_task work 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).

  4. Force-close — remaining transports are closed in a loop capped at min(graceful_shutdown_timeout, 1s).

  5. Cancel stragglers — pending app.create_task coroutines are cancelled.

  6. Bootstrap teardown — the generator after yield runs (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).