Mapping errors to HTTP responses
Intentional HTTP outcomes — default to HttpException for 4xx/5xx bodies you control, and RedirectException (or responses.redirect) for redirects. Subclass and override respond on HttpException if you need a non–plain-text body.
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. on_error resolves by MRO (most specific type wins); see Exception handling.
on_error matches by exception type (MRO), not by where it was raised. A handler for pydantic.ValidationError runs for every ValidationError, including validation of rows or other data after the request is parsed, so the client can get 422 when the real fault should be 5xx. Prefer HttpException or a narrow app-defined type at HTTP boundaries.
Handlers run only while the response has not started; after headers go out, fixups are limited to logging and spans (Writer).
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:
HttpException— raise when you already know the HTTP outcome (missing query param, bad integer, wrong content type). The defaulton_errorhandler turns it into a response (Exception handling).app.on_error(exc_type, handler)— register once for types you want to map the same way everywhere (for examplejson.JSONDecodeErrorwhen 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 (QueryParams); 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:
from stario import App, Context, HttpException, Writerimport stario.responses as responses 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: invalid UTF-8 typically raises UnicodeDecodeError, while bad JSON after valid UTF-8 raises json.JSONDecodeError. Register handlers for both if you want the same 400 body for either failure, or decode explicitly and map errors yourself.
import jsonfrom json import JSONDecodeError from stario import App, Context, HttpException, Span, Writerimport stario.responses as responses 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) -> None: app.on_error(JSONDecodeError, bad_json) app.on_error(UnicodeDecodeError, bad_json) app.post("/greet", greet, name="greet")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.
from pydantic import BaseModel, ValidationError from stario import App, Context, Span, Writerimport stario.responses as responses class CreateItem(BaseModel): name: str qty: int 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) -> None: app.on_error(ValidationError, validation_failed) app.post("/items", create_item, name="create_item")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. With model_validate_json, some malformed JSON may surface as ValidationError (for example json_invalid) rather than JSONDecodeError—split 400 vs 422 using error types/codes you care about, or decode with json.loads first if you want stdlib syntax errors only.
Query strings are strings end to end; QueryParams.as_dict() is a convenient input to model_validate for GET-style validation:
from pydantic import BaseModel, ValidationError from stario import Context, HttpException, Writerimport stario.responses as responses 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.
Related
Runtime — Application —
on_errorresolution (MRO, most specific wins).Responses — Writer — when headers are fixed.
Testing with TestClient — assert status codes and JSON bodies in-process.