Most errors in a handler are one line: write the status and the body, and return. Everything else on this page is for the cases where one line is not enough: an exception type that should always become the same response, an error page in your own HTML, and a 404 that matches the section of the site it belongs to.
Stario does not bundle a validation library and does not guess at mappings. The decision of which exception means which status is yours, made at the HTTP boundary.
1. Answer in the handler#
from stario import Context, Writer async def double(c: Context, w: Writer) -> None: raw = c.req.query.get("n") if raw is None: w.text("missing query parameter 'n'", status=400) return try: n = int(raw) except ValueError: w.text("'n' must be an integer", status=400) return w.text(str(n * 2))Writing the response is the mapping. The handler knows exactly what went wrong, and the status is visible in the same line. For a redirect, call w.redirect(url, status=303) or raise RedirectException; RequestBodyError (413/408) and RedirectException map themselves when they escape a handler.
2. Map a type with catch_errors#
When an exception type should become the same response wherever it is raised, define the exception and map it once in bootstrap. catch_errors is middleware: it wraps the routes under the prefix you pass to app.use.
from stario import App, Context, Route, Span, Writerfrom stario.http.middleware import catch_errors class HttpError(Exception): def __init__(self, status_code: int, detail: str = "") -> None: self.status_code = status_code self.detail = detail super().__init__(detail) async def error_text(c: Context, w: Writer, exc: BaseException) -> None: assert isinstance(exc, HttpError) w.text(exc.detail, status=exc.status_code) async def double(c: Context, w: Writer) -> None: raw = c.req.query.get("n") if raw is None: raise HttpError(400, "missing query parameter 'n'") try: n = int(raw) except ValueError: raise HttpError(400, "'n' must be an integer") w.text(str(n * 2)) async def bootstrap(app: App, span: Span): app.use("/", catch_errors(HttpError, respond=error_text)) app.add(Route("GET /double"), double) yieldMatching is except semantics: the listed types and their subclasses. Scope the middleware to the prefix where the mapping is right. catch_errors(JSONDecodeError, UnicodeDecodeError, respond=bad_json) maps several types to one responder.
Be careful with broad types. catch_errors matches by type, not by where the exception was raised. A responder for pydantic.ValidationError runs for every ValidationError under its prefix, including one raised while validating a database row after the request was parsed, and the client would see a 422 for what is really a server fault. Register a type only when the mapping is right everywhere it can occur; otherwise catch it in the handler.
With pydantic the safe shape is a narrow model at the boundary:
from pydantic import BaseModel, ValidationError from stario import Context, Writer class CreateItem(BaseModel): name: str qty: int async def create_item(c: Context, w: Writer) -> None: try: item = CreateItem.model_validate_json(await c.req.body()) except ValidationError as exc: w.text(exc.json(), status=422) return w.json({"ok": True, "name": item.name, "qty": item.qty}, status=201)3. An error page in your own HTML#
Point the responder at a page instead of text. The same layout the rest of the site uses is the right one here.
from stario import App, Context, Span, Writer, hfrom stario.http.middleware import catch_errors from app.views import layout async def error_page(c: Context, w: Writer, exc: BaseException) -> None: assert isinstance(exc, HttpError) page = layout( h.H1(str(exc.status_code)), h.P(exc.detail or "Something went wrong."), ) w.html(page, status=exc.status_code) async def bootstrap(app: App, span: Span): app.use("/", catch_errors(HttpError, respond=error_page)) yieldA responder runs only while nothing has been sent. Once the status line and headers are on the wire, the response cannot change; catch_errors re-raises, the runtime records the failure on the request span, and the connection is aborted. That is why handlers should validate before they write.
4. A 404 for each part of the site#
A request that matches no route gets the default 404. app.not_found(prefix, handler) replaces it for everything under a prefix, so an API returns JSON and the rest of the site returns a page.
from stario import App, Context, Span, Writer, h from app.views import layout async def page_not_found(c: Context, w: Writer) -> None: w.html(layout(h.H1("Nothing lives here.")), status=404) async def api_not_found(c: Context, w: Writer) -> None: w.json({"error": "not_found", "path": c.req.path}, status=404) async def bootstrap(app: App, span: Span): app.not_found("/", page_not_found) app.not_found("/api", api_not_found) yieldThe nearest prefix wins. app.method_not_allowed(prefix, factory) does the same for 405; the factory receives the set of allowed methods and returns a handler, so it can set the Allow header itself.
By the way#
RequestBodyErroranswers itself with 413 or 408 when a body is oversize or stalls.RedirectExceptionanswers itself withw.redirect.StarioErrorandStarioRuntimemean the framework was used wrongly, such as writing twice to one response. Uncaught, they become a 500 and a failed span. Fix the code rather than mapping them.ClientDisconnectedwhile reading a body means the peer left. There is nobody to answer, so the connection is closed without a response.Every error path above lands on the request span with the status and, for an uncaught exception, the traceback. Traces in SQLite has the query that lists them.
The reference for all of this is Runtime.