Mapping errors to HTTP responses

Intentional HTTP outcomes — default to HttpException for 4xx/5xx bodies you control, and RedirectException (or responses.redirect) for redirects. HttpException rejects non-4xx/5xx status codes at construction. app.on_error(HttpException, …) replaces the built-in plain-text handler when you need JSON or HTML error bodies everywhere.

Framework failure typesStarioError / StarioRuntime mean invalid framework usage or response phase; uncaught, they become 500. ClientDisconnected during body reads aborts the connection (no body). See Exception handling.

Central mapping — add app.on_error(exc_type, handler) only when one HTTP mapping for that exception type is correct everywhere it can be raised. Handlers must be async def. on_error resolves by MRO (most specific type wins); see Exception handling.

Handlers run only while the response has not started; after headers go out, fixups are limited to logging and spans (Writer). Registered handlers must call a responses.* helper (or finish the Writer); otherwise the client gets 500.

When input validation fails, you usually want a stable status code and a predictable body—without copying the same try/except branches in every route. Stario gives you two layers:

  1. HttpException — raise when you already know the HTTP outcome (missing query param, bad integer, wrong content type). The default on_error handler turns it into a response (Exception handling).

  2. app.on_error(exc_type, handler) — register once for types you want to map the same way everywhere (for example json.JSONDecodeError when every decode failure should look alike).

The examples below use input validation as the story: query parameters, a JSON body with the standard library, then an optional Pydantic path. Stario does not bundle a validation library; adding Pydantic (or msgspec, cattrs, …) is an app dependency—see No validation layer in the framework.

Query parameters

Read values from c.req.query (ParsedQuery); use get for a single string, or as_dict when you want one value per key for a schema.

For a small API, raise HttpException in the handler keeps the rule next to the route:

python
import stario.responses as responses
from stario import Context, HttpException, Writer
 
 
async def double(c: Context, w: Writer) -> None:
    raw = c.req.query.get("n")
    if raw is None:
        raise HttpException(400, "missing query parameter 'n'")
    try:
        n = int(raw)
    except ValueError:
        raise HttpException(400, "'n' must be an integer")
    responses.text(w, str(n * 2))

Register the handler in bootstrap (for example app.get("/double", double)). That maps “bad or missing input” to status 400 with a plain-text explanation—appropriate when the client did not satisfy a simple contract.

JSON body with json.loads

Buffer the body with await c.req.body(), then decode. json.loads on bytes decodes UTF-8 first: some invalid byte sequences raise UnicodeDecodeError, others JSONDecodeError; bad JSON after valid UTF-8 raises JSONDecodeError. Register handlers for both if you want the same 400 body for either failure, or decode explicitly and map errors yourself.

python
import json
from json import JSONDecodeError
 
import stario.responses as responses
from stario import App, Context, HttpException, Span, Writer
 
 
async def bad_json(c: Context, w: Writer, exc: JSONDecodeError | UnicodeDecodeError) -> None:
    responses.text(w, "Request body is not valid JSON", 400)
 
 
async def greet(c: Context, w: Writer) -> None:
    raw = await c.req.body()
    if not raw:
        raise HttpException(400, "expected a JSON object in the body")
    data = json.loads(raw)
    if not isinstance(data, dict):
        raise HttpException(400, "JSON value must be an object")
    name = data.get("name")
    if not isinstance(name, str) or not name.strip():
        raise HttpException(400, "'name' must be a non-empty string")
    responses.json(w, {"hello": name.strip()})
 
 
async def bootstrap(app: App, span: Span):
    app.on_error(JSONDecodeError, bad_json)
    app.on_error(UnicodeDecodeError, bad_json)
    app.post("/greet", greet)
    yield

Here HttpException covers semantic problems (empty body, wrong shape, bad field). JSONDecodeError and UnicodeDecodeError are wire problems; registering both avoids repeating except blocks in every JSON handler.

Pydantic: schema validation and 422

If you add pydantic, BaseModel.model_validate_json (or TypeAdapter) validates in one step. The Reading and writing signals how-to covers the Datastar read_signals / patch_signals path; this section is ordinary JSON request bodies. The example registers on_error(ValidationError, …) to show a compact 422 body; whether that is safe depends on the warning above.

python
from pydantic import BaseModel, ValidationError
 
import stario.responses as responses
from stario import App, Context, Span, Writer
 
 
class CreateItem(BaseModel):
    name: str
    qty: int
 
 
async def validation_failed(c: Context, w: Writer, exc: ValidationError) -> None:
    responses.json(w, {"detail": exc.errors()}, status=422)
 
 
async def create_item(c: Context, w: Writer) -> None:
    raw = await c.req.body()
    item = CreateItem.model_validate_json(raw)
    responses.json(w, {"ok": True, "name": item.name, "qty": item.qty}, status=201)
 
 
async def bootstrap(app: App, span: Span):
    app.on_error(ValidationError, validation_failed)
    app.post("/items", create_item)
    yield

422 is common when JSON is well-formed but does not match the schema; 400 fits “not even parseable JSON” if you handle JSONDecodeError separately, as above.

Query strings are strings end to end; ParsedQuery.as_dict() is a convenient input to model_validate for GET-style validation:

python
from pydantic import BaseModel, ValidationError
 
import stario.responses as responses
from stario import Context, HttpException, Writer
 
 
class SearchQuery(BaseModel):
    q: str
    limit: int = 20
 
 
async def search(c: Context, w: Writer) -> None:
    try:
        params = SearchQuery.model_validate(c.req.query.as_dict())
    except ValidationError as exc:
        raise HttpException(422, str(exc)) from exc
    responses.json(w, {"q": params.q, "limit": params.limit})

Register search in bootstrap and use a global on_error(ValidationError, …) only if that matches every ValidationError in the app (see the warning above).

Custom exception types

Define a small domain exception and register it when it is not HttpException—see OrderNotFound in Runtime — Exception handling. Prefer narrow types at HTTP boundaries so on_error stays predictable.