Errors¶
Failures are typed classes you raise. A raised jero error short-circuits the handler and becomes a consistent JSON body with the right status — the same shape whether the framework raised it (a 404 route miss, a 422 validation failure) or you did:
from msgspec import Struct
from jero import BaseApp, HTTPError, Resource
class OutOfStockError(
HTTPError,
type="out-of-stock",
title="Widget out of stock",
status=409,
): ...
class WidgetPath(Struct):
widget_id: str
class Widget(Struct):
id: str
class WidgetResource(Resource, path="/widgets"):
async def read_one(self, path: WidgetPath) -> Widget:
if path.widget_id == "sold-out":
raise OutOfStockError()
return Widget(id=path.widget_id)
class App(BaseApp):
async def wire(self) -> None:
self._include_resource(WidgetResource())
app = App()
{"type": "out-of-stock", "title": "Widget out of stock", "status": 409}
Because errors are classes, they carry a schema — the OpenAPI generator documents exactly which errors an operation raises, body shape included.
Problem Details¶
Every framework and application HTTPError uses a typed
RFC 9457 Problem Details body. jero
deviates from the RFC in one way: type is a stable machine-readable code, not a
URI. Clients use type, never title or detail, for programmatic decisions.
For common statuses, jero ships errors ready to raise — one line, same typed problem body the framework itself sends:
from jero import ConflictError, ForbiddenError, GoneError, NotFoundError, TooManyRequestsError
raise NotFoundError() # 404 {"type": "not-found", ...}
raise ForbiddenError() # 403 {"type": "forbidden", ...}
raise ConflictError() # 409 {"type": "conflict", ...}
raise GoneError() # 410 {"type": "gone", ...}
raise TooManyRequestsError() # 429 {"type": "too-many-requests", ...}
(AuthenticationRequiredError, ValidationFailedError, and the other statuses the
framework raises itself are exported too.) These carry the status semantics and nothing
more — the moment an error means something domain-specific, define your own static
error by subclassing HTTPError, as in the example above.
There is no raise HTTPError(409, detail="widget already exists") form. An ad-hoc
detail string becomes a contract clients regex against, and a response without a
stable type leaves them dispatching on status alone. The class is four lines, once,
and every raise site stays consistent.
Parameterized errors¶
When the human-readable detail contains runtime values, pair it with typed params:
from dataclasses import dataclass
from msgspec import Struct
from jero import DataclassHTTPError
class WidgetNotFoundParams(Struct, rename="camel"):
widget_id: str
@dataclass
class WidgetNotFoundError(
DataclassHTTPError[WidgetNotFoundParams],
type="widget-not-found",
title="Widget not found",
status=404,
detail_template="Widget {widget_id} not found",
):
widget_id: str
def __post_init__(self) -> None:
self._set_params(WidgetNotFoundParams(widget_id=self.widget_id))
raise WidgetNotFoundError(widget_id="widget-id")
The response includes both "detail": "Widget widget-id not found" and
"params": {"widgetId": "widget-id"}. detail and params cannot appear separately.
An optional docs="https://..." class option adds documentation for either error form.
Any other uncaught exception becomes the static internal-server-error problem; server
internals never leak to the client.
Bring your own error body¶
Problem Details is the default, not a requirement: starting an API from
scratch, use the HTTPError family above. StructHTTPError
is the channel for backporting jero onto an existing error standard — when your
wire format is already decided, subclass it with your own body Struct. The class options
declare how every field of the body gets its value — pinned constants, templates
rendered from raise-time params, the class's status — and whatever is left over is a
raise-time parameter. Decorate with @dataclass and declare those params as fields:
the generated __init__ gives you a statically-typed raise site.
from dataclasses import dataclass
from msgspec import Struct
from jero import BaseApp, Endpoint, StructHTTPError
class HouseBody(Struct, rename="camel"):
error_code: str
error_message: str
status_code: int
@dataclass
class DocumentTooLargeError(
StructHTTPError[HouseBody],
status=413,
description="Document too large", # the OpenAPI response description
consts={"error_code": "abcd"}, # pinned: wire value + schema const
templates={"error_message": "Document is {size} bytes; the limit is 50MB"},
status_field="status_code", # fed the class's status
):
size: int # the raise-time param, typed
class Accepted(Struct):
size: int
class DocumentsEndpoint(Endpoint, path="/documents"):
async def post(self, content: bytes) -> Accepted:
if len(content) > 50_000_000:
raise DocumentTooLargeError(size=len(content))
return Accepted(size=len(content))
class App(BaseApp):
async def wire(self) -> None:
self._include_endpoint(DocumentsEndpoint())
app = App()
{"errorCode": "abcd", "errorMessage": "Document is 50000001 bytes; the limit is 50MB", "statusCode": 413}
Total coverage is enforced loud at class definition: every body field must be fed by
exactly one source (a const, a template, the status, or a same-named param) — an
unknown field name, a field fed twice, a template on a non-str field, or a status
field that isn't an int all fail before the app can start. The wire representation is
composed fresh at encode time from a model built once at class creation; nothing you
pass is ever mutated, and the pinned values appear in the OpenAPI schema as enum
consts, so the spec documents exactly which code (and status) each error carries.
One more source covers richer house formats: params_field= — a Struct-typed
body field the raise-time params nest into, so the response carries the rendered text
and the raw values:
from dataclasses import dataclass
from msgspec import Struct
from jero import BaseApp, Endpoint, StructHTTPError
class ThingExtensions(Struct, rename="camel"):
thing: str
class CompanyBody(Struct, rename="camel"):
error_code: str
error_description: str
extensions: ThingExtensions
@dataclass
class ThingFailedError(
StructHTTPError[CompanyBody],
status=422,
description="Thing failed",
consts={"error_code": "abc"},
templates={"error_description": "This {thing} has failed"},
params_field="extensions",
):
thing: str
class Ok(Struct):
ok: bool
class ThingsEndpoint(Endpoint, path="/things"):
async def post(self, content: bytes) -> Ok:
_ = content
raise ThingFailedError(thing="my-thing")
class App(BaseApp):
async def wire(self) -> None:
self._include_endpoint(ThingsEndpoint())
app = App()
{"errorCode": "abc", "errorDescription": "This my-thing has failed",
"extensions": {"thing": "my-thing"}}
The description renders server-side from the params, and the same params ship raw in
extensions for clients that want the structured values. Templates follow ordinary
str.format rules (escape literal braces as {{thing}}). For a shared shape across
errors, make the base generic over its varying part
(class CompanyBody[E: Struct](Struct): ... extensions: E) and pin E per error body.
Catch scope: except HTTPError catches only the Problem family; except BaseHTTPError
means "any jero error", both families.
House-wide error format¶
StructHTTPError covers errors you raise. The framework's own errors — 404 route
misses, 422 validation failures, the unexpected-exception 500 — are Problem-family. To
render those in your house shape too, register an ErrorBodyAdapter: the app-wide
renderer for the Problem family.
from msgspec import Struct
from jero import BaseApp, Endpoint, ErrorBodyAdapter, HTTPError
class HouseBody(Struct, rename="camel"):
error_code: str
error_message: str
class HouseErrorAdapter(ErrorBodyAdapter[HouseBody]):
status_field = "status_code"
def compose(self, error: HTTPError) -> HouseBody:
return HouseBody(error_code=error.type, error_message=str(error))
class Health(Struct):
status: str
class HealthEndpoint(Endpoint, path="/healthz"):
async def get(self) -> Health:
return Health(status="ok")
class App(BaseApp):
async def wire(self) -> None:
self._include_error_adapter(HouseErrorAdapter())
self._include_endpoint(HealthEndpoint())
app = App()
Now GET /nope returns
{"errorCode": "not-found", "errorMessage": "Not found", "statusCode": 404} — and the
same for every Problem-family error, including ones your
exception handlers translate into. str(error) is the
uniform human message (the title, or the rendered detail for parameterized errors);
error.type is the stable machine code to map into your own vocabulary.
StructHTTPErrors render themselves, so each error has exactly one renderer. Keep
compose pure — it receives only the error; request-correlated data belongs in
exception handlers. An adapter failure is contained: logged, with the Problem body sent
instead. At most one adapter per app, and the derived OpenAPI error responses
follow it.
Custom exception handlers¶
An exception handler is any hand-wired object with one typed handle_exception method.
No base class or decorator is required. Return an ExceptionResponse to replace the
exception, or None to continue default handling (HTTPError becomes its problem;
another exception becomes the generic 500 problem):
from msgspec import Struct
from jero import BaseApp, Endpoint, ExceptionResponse
class UpstreamError(Exception):
"""A call to an upstream service failed."""
def __init__(self, *, retryable: bool, safe_to_expose: bool) -> None:
super().__init__("upstream call failed")
self.retryable = retryable
self.safe_to_expose = safe_to_expose
class Status(Struct):
ok: bool
class FailureBody(Struct):
code: str
class FailureHeaders(Struct):
retry_after: int
class UpstreamHandler:
def handle_exception(
self, exception: UpstreamError
) -> ExceptionResponse[FailureBody, FailureHeaders] | None:
if not exception.safe_to_expose:
return None
return ExceptionResponse(
status_code=503 if exception.retryable else 502,
json=FailureBody(code="upstream-failed"),
headers=FailureHeaders(retry_after=30),
)
class StatusEndpoint(Endpoint, path="/status"):
async def get(self) -> Status:
raise UpstreamError(retryable=True, safe_to_expose=True)
class App(BaseApp):
async def wire(self) -> None:
self._include_exception_handler(UpstreamHandler())
self._include_endpoint(StatusEndpoint())
jero infers every type from the method signature at wiring. Registering two handlers
for the same exception type is a WiringError; handlers for a base and subclass may
coexist, and the nearest type in the exception's MRO wins. Exceptions raised after a
streaming response has started cannot replace that response. ExceptionResponse
requires an error status_code from 400 through 599; if a custom handler itself fails,
jero sends the generic 500 problem without recursively dispatching the new failure.
A handler may return a union of concrete response types when the exception occurrence
determines its body, headers, or status; every union member is validated at wiring.
It may also return a union of declared HTTPError subclasses; their type, title,
status, and optional detail_template remain static class-level contracts, and the
handler only selects and constructs the appropriate error instance.
Related pages¶
- REST semantics — the status every framework-raised error uses, and why.
- OpenAPI & docs — declaring which errors an operation raises, so the spec documents them.