Routing
Overview
This page focuses on registration and matching: App / Router, middleware, route params, and mount. Per-request Context and Request (c / c.req) are documented on Request and context. For responses, body bytes, streaming, and cookies, see Responses. For process lifecycle, shutdown, and url_for details, see Runtime.
The HTTP server holds a single App instance. The CLI or Server constructs it; your bootstrap(app, span) registers routes and mount subtrees on that shared instance. For every request the stack invokes await app(c, w), which opens telemetry, resolves a handler from one merged route trie, runs your code, applies on_error when something raises, and always finishes w.
You normally import HTTP types from the package root (Handler, Middleware, and Router are re-exported alongside Context, App, Writer, and Span):
from stario import App, Context, Handler, Middleware, Router, Span, WriterShapes you use every day
Handler—async def handler(c: Context, w: Writer) -> None. Your route target.Middleware—(inner: Handler) -> Handler, composed at registration time (Middleware).App— Subclass ofRouterwithurl_for,on_error,create_task, and__call__(c, w). The CLI orServerconstructs the instance; you register routes and mounts on that object insideasync def bootstrap(app: App, span: Span)(or an async context manager bootstrap). Do not treatRouteras the HTTP entrypoint: the server awaitsApp.__call__, which resolves the merged trie.Router— Same registration surface asApp(get,handle,mount, …). Use it for subtrees you attach withmount, or for libraries that only define routes. The rootAppyou receive in bootstrap is still this type.
The named_routes map lists every reverse-routable pattern: only routes registered with name= appear, and after mount the merged map uses canonical prefixed patterns (for example a child /users under mount("/api", …) becomes /api/users in the stored key). Nothing is injected without a name=. Pattern syntax (path-only vs host-qualified, wildcards, and composition) is described in the next sections. For design rationale (404 vs 405, trie, url_for), see Router design below.
Resolution rules
Inside App.__call__, route resolution applies these behaviors before your handler runs:
Non-root paths with a trailing slash 308 redirect to the slashless URL.
Path matches but HTTP method does not → 405 with an
Allowheader.Successful match →
c.routeis aRouteMatch(pattern string + captured params). On 404 (and some 405 cases),c.route.patternis empty—guard before reading route params.
Reverse URLs: Reverse URLs.
Middleware
The Middleware type is a handler factory: given the next handler inner, you return a new async handler that may run logic before await inner(c, w), after it, or around it. Wrapping happens when you register the route (or when you push_middleware / mount), not on every request as a separate object.
Apply middleware in either place: Router.push_middleware(...) wraps the whole router (including handlers already registered, which get re-wrapped), or pass middleware=(...) to get / post / handle / mount for that registration only (stacked after any router-global middleware).
Typical uses: attach a request id or user record to c.state, enforce auth and bail out with responses + status before calling inner, add per-route logging, or normalize context once for every handler under a subtree.
Ordering: for each route, Stario composes global_router_middlewares + route_specific_middlewares in registration order by folding wrapped = mw(wrapped). The last middleware in that tuple ends up outermost, so it runs first on the way in and last on the way out (after await inner returns). Register broader concerns (logging, auth gates) after narrower ones if you want them to run first on the request.
from stario import App, Context, Handler, Span, Writer def with_request_id(inner: Handler) -> Handler: async def wrapped(c: Context, w: Writer) -> None: c.state["request_id"] = c.req.headers.get("x-request-id") or "local" await inner(c, w) return wrapped def with_greeting(inner: Handler) -> Handler: async def wrapped(c: Context, w: Writer) -> None: c.state["greeting"] = "hello" await inner(c, w) return wrapped async def home(c: Context, w: Writer) -> None: msg = f"{c.state['greeting']} {c.state['request_id']}".encode() w.respond(msg, b"text/plain") async def bootstrap(app: App, span: Span) -> None: app.push_middleware(with_request_id) # registered first; see ordering note below app.push_middleware(with_greeting) # outermost inbound (runs first on the request) app.get("/", home) # Equivalent for this route only: app.get("/", home, middleware=(with_request_id, with_greeting))Mounted subtrees receive the parent’s accumulated global middleware at merge time; mount(..., middleware=(...)) adds wrappers only for routes coming from that child.
Route parameters
After a successful match, c.route.params is a read-only map (MappingProxyType) from parameter names to strings. Names come from {id} and {rest...} segments on the path and from {tenant} / {rest...} on the host.
async def user_detail(c: Context, w: Writer) -> None: user_id = c.route.params["user_id"] # safe once this handler is running for /users/{user_id} ... async def tenant_dashboard(c: Context, w: Writer) -> None: sub = c.route.params["subhost"] # from {subhost}.example.com/... ...Use dict(c.route.params) when you need a mutable copy. Prefer c.route.params["key"] when the route pattern guarantees the key; use .get("key") only when the same handler might run in contexts where the key is optional.
Host vs path
The first / in a pattern splits host from path. The string api/users is not /api/users; it is parsed as host api and path users. Use a leading slash on API paths: /api/users.
Path-only and host-qualified patterns
Every route pattern must contain a /. The first / splits the string into a host key (possibly empty) and a path. Registration APIs (get, post, handle, mount) all use the same syntax.
Path-only routes (any Host)
If the pattern starts with /, the host key is empty. The route matches that path for every value of the request’s Host header (or an empty host).
from stario import App, Context, Span, Writer async def bootstrap(app: App, span: Span) -> None: async def home(c: Context, w: Writer) -> None: w.respond(b"ok", b"text/plain") async def user_detail(c: Context, w: Writer) -> None: user_id = c.route.params["user_id"] ... async def serve_file(c: Context, w: Writer) -> None: ... app.get("/", home) app.get("/users/{user_id}", user_detail, name="user") app.get("/files/{path...}", serve_file, name="files") # catch-all: `{name...}` only as the final `{…}` segment; captures the rest of the path (slashes included)| Piece | Meaning |
|---|---|
Literal users | That path segment must match exactly. |
{user_id} | Captures one segment (no /). See Route parameters. |
{path...} | Catch-all: rest of the path, slashes included. Only allowed as the last path segment. |
c.route.pattern is the canonical pattern string (e.g. /users/{user_id}), not the request URL.
Host-qualified routes
If there is text before the first /, that text is a host pattern: dot-separated labels in normal notation (api.example.com), compared case-insensitively. You can mix literals and {param} segments on the host. On the host, a {name...} catch-all may only be the first label (equivalent to “no interior catch-all” on the path).
from stario import App, Context, Router, Span, Writer async def bootstrap(app: App, span: Span) -> None: async def list_users(c: Context, w: Writer) -> None: ... async def static_or_proxy(c: Context, w: Writer) -> None: ... async def dashboard(c: Context, w: Writer) -> None: _ = c.route.params["subhost"] ... api = Router() api.get("api.example.com/users", list_users) tenant = Router() tenant.get("/dashboard", dashboard) app.get("cdn.example.com/assets/{name...}", static_or_proxy) app.mount("{subhost}.example.com/", tenant)GET /dashboard with Host: acme.example.com fills c.route.params["subhost"] with acme.
You author host patterns in normal form (subdomain.example.com/path). Matching is case-insensitive and label-by-label; see below for how the trie combines host and path.
One trie: host and path together
If a pattern includes a host segment (anything before the first /), that segment is merged into the same route trie as path-only routes, mount prefixes, static assets, and everything else registered on the app. Dispatch uses one trie; each request performs one walk from the root. The Host header and the path are resolved together, not in two separate passes.
The matcher compares host labels in reverse order from human-readable form (TLD first). You still write app.example.com in code; internally the walker steps com, then example, then app, then the path.
Host header | Labels walked (in order) |
|---|---|
app.example.com | com → example → app |
example.com | com → example |
Registration example:
sub = Router()sub.get("/", sub_home) app.mount("app.example.com/", sub)app.get("/", apex_home) # path-only `/` (no host in the pattern)| Request | Result |
|---|---|
GET /, Host: app.example.com | Matches com → example → app, then path /, so the handler is sub_home. |
GET /, Host: example.com | Matches com → example, then the trie may still expect another host label (for example app) before path matching. The request does not get a second pass that tries apex_home. The outcome is often 404 even though apex_home is registered on /. |
Dispatch does not mean “try host-specific routes, then fall back to path-only routes.” Path-only handlers are not a fallback layer after host matching stops. If you combine host mounts and bare path-only routes, you can end up with a partial host match and a 404, in particular when an apex name such as example.com and a subdomain such as app.example.com share the same initial labels.
Host-based routing: pick one style
Prefer path-only routes for the whole app, or host-qualified patterns (hostname/… routes and mounts) everywhere. Registering both styles on one app is where partial matches and unexpected 404s tend to appear.
Combining routers with mount
mount(pattern, child) merges one trie into another: one combined tree, one dispatch pass in App.__call__. The child must expose root and named_routes (another Router, App, or e.g. StaticAssets).
Path prefix:
app.mount("/api", api)— child routes are registered as/api/...on the parent. If the child only uses path-only patterns, any host can hit those URLs.Host-only mount:
app.mount("api.example.com/", child)— the trailing slash after the host is required (mount("api.example.com", child)is rejected with a hint to useapi.example.com/). The child’s path-only routes only see requests whoseHostisapi.example.com(not subdomains).Host + path prefix:
app.mount("example.com/v1", chat)— restricts to that host and prefixes paths with/v1.url_fornames from the child include both host and prefix.
Example in bootstrap: path-only subtree under /api, a host-only mount (note the / after the hostname—mount("api.example.com", …) is rejected), and a second subtree without overlapping name= keys so both can attach to the same app:
from stario import App, Context, Router, Span, Writer async def bootstrap(app: App, span: Span) -> None: async def health(c: Context, w: Writer) -> None: ... async def list_users(c: Context, w: Writer) -> None: ... api = Router() api.get("/users", list_users, name="users") edge = Router() edge.get("/users", list_users) # no name=: safe to mount alongside the api router, which uses name="users" v1 = Router() v1.get("/users", list_users, name="v1_users") app.get("/health", health) app.mount("/api", api) app.mount("api.example.com/", edge) app.mount("api.example.com/v1", v1)Reusing the same Router instance at different prefixes is fine when patterns and name= keys do not collide; overlapping mounts or trie merges that disagree on handlers can still raise StarioError. Each name= from merged named_routes must be unique across the whole app—duplicate names raise StarioError. mount reads child.root without mutating the child, so one Router can be merged from a wrapper (partner.mount("/v1", core) then app.mount("api.example.com/", partner)) when you omit or vary name=.
If the child already uses host-qualified patterns (e.g. api.example.com/users), a path-only mount still applies a path prefix: app.mount("/v1", child) matches GET /v1/users with Host: api.example.com; c.route.pattern becomes api.example.com/v1/users.
Conflicts and limits
Host defined twice: if
mount’s pattern includes a host and the child’s routes also use a host prefix (anything before the first/—for exampleapi.example.com/usersor, less commonly,api/internalmeaning hostapi), stario raisesStarioError(“Host matching already defined”). Put host rules in either themountpattern or the child’s route strings—not both.Mount prefix cannot end in a path catch-all: you cannot
mount("/files/{rest...}", subtree). Register a catch-all withget("/files/{rest...}", ...)(or mountStaticAssets) instead.name=collisions: mountednamed_routeskeys must be unique across the whole app.
Per-mount middleware and global middleware order are covered under Middleware.
Router design
These notes mirror how dispatch works in stario.http.router — the same facts as the old “router internals” essay, kept here so routing lives in one place.
Why the router is a trie
Stario stores routes in a trie so matching stays shaped like the URL itself: optional host segments, then path segments, then HTTP method. That keeps dispatch explicit and makes route conflicts surface at registration time instead of being hidden behind registration order.
Why 404 and 405 are separate
404 means the URL shape is unknown — no trie leaf matched the path/host.
405 means the path matched but the HTTP method did not — the trie found a node, but not a handler for this verb.
That distinction keeps the HTTP surface honest and makes Allow headers meaningful.
Why not_found_handler is separate from route middleware
A trie-level 404 happens before a concrete route handler is chosen. not_found_handler can run without pretending a real route matched. Shared behavior on 404 belongs in that handler or in an explicit catch-all route.
Why named routes live outside dispatch
url_for works from a name table rather than walking the trie. Dispatch answers “what handles this request?” and reverse routing answers “what path belongs to this name?” Keeping those jobs separate makes reverse lookups cheap and keeps matching rules independent from link generation.
Why trailing slashes normalize
Stario redirects /foo/ to /foo (308) so each resource has one canonical URL. That keeps linking, caching, and reverse routing simpler.
Why mount keeps composition explicit
mount merges a child router under a prefix instead of creating a hidden sub-application boundary. The result is still one composed route tree with explicit names, middleware order, and matching rules.
Router (registration)
class Router(*, middleware=(), not_found_handler=None)
Explicit route table: register methods and paths, optionally mount subtrees into one trie.
The live server invokes App.__call__, which walks this table. Do not treat Router as the HTTP entrypoint—call await app(c, w) on an App instance.
Router.push_middleware(*middleware)
Append middleware and re-wrap all handlers already on the trie (in addition to future registrations).
Router.handle(method, pattern, handler, *, middleware=(), name=None)
Attach a handler to one HTTP method and pattern.
Parameters
- method—Verb such as
GETorPOST(case as you prefer; compared as given). - pattern—
/pathwith optional{id}segments, orhost.name/pathfor host-based routes. - handler—Async
(context, writer) -> Noneinvoked when this route wins. - middleware—Extra wrappers for this route only (after this router’s
_middlewaresstack). - name—If set, registers a reverse-lookup key for
App.url_for.
Raises
StarioError: On duplicate name or invalid pattern text.
Notes
Catch-all segments use {name...} and must be last on the path (or first on the host).
Router.mount(pattern, child, *, middleware=())
Graft another router or static tree under pattern without copying its handlers manually.
Parameters
- pattern—Mount point (same syntax as routes; must not end with a catch-all segment).
- child—Object exposing
root(trie node) andnamed_routes(e.g. anotherRouterorStaticAssets). - middleware—Extra middleware applied only to routes coming from
child.
Raises
StarioError: If child is not a subtree, host rules conflict, or named routes collide.
Notes
Static assets and sub-routers both implement the Subtree protocol expected here.
Router.delete(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("DELETE", ...).
Router.get(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("GET", ...).
Router.head(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("HEAD", ...).
Router.options(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("OPTIONS", ...).
Router.patch(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("PATCH", ...).
Router.post(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("POST", ...).
Router.put(pattern, handler, *, middleware=(), name=None)
Shorthand for handle("PUT", ...).
App (application)
Adds error handling, url_for, and shutdown-aware create_task. The protocol awaits App.__call__(c, w); that resolves routes on this instance’s trie and wraps the call in span lifecycle and writer.end().
class App(*, middleware=())
Concrete app type: everything on Router plus errors, reverse URLs, 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, always call w.end().
Parameters
- c—Request context (
app,req,span,route,state). - w—Response writer for this message on the connection.
Notes
Trailing slashes (except /) get 308 to the router-normalized path (same rules as find_handler); wrong method on a matching path yields 405. If a registered error handler raises, the failure is logged and a 500 is sent when no response has started yet.
App.on_error(exc_type, handler)
Register a handler for uncaught exceptions of type exc_type (subclasses use MRO; most specific wins).
Parameters
- exc_type—Exception class to match.
- handler—Receives
(context, writer, exc); may returnNoneor an awaitable.
Notes
Only runs while the writer has not started (w.started is false); HttpException is registered by default.
App.create_task(coro, *, name=None)
Schedule a coroutine on the running loop and retain the task until it completes.
Parameters
- coro—Coroutine to run.
- name—Optional task name for debuggers.
Returns
The new asyncio.Task.
Raises
StarioError: If no event loop is running (call from async request or app code only).
App.url_for(name, *, params=None, query=None)
Build a path (and optional query string) from a registered name.
Parameters
- name—Value passed as
name=toget/handleor from mountedStaticAssets. - params—
str.formatmapping for{placeholders}in the stored pattern. - query—Values merged with
urllib.parse.urlencode(lists/tuples repeat the key).
Returns
A path starting with /, or host/... style when the route used host matching, plus ?... when query is set.
Raises
StarioError: If name is unknown or placeholders are missing from params.
async App.join_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.
Context
See Request and context.
Request
See Request and context.