Fail on purpose, safely
Error Handling
Caspian separates expected client failures from unexpected internal ones,
renders page failures through the nearest error.py
boundary, keeps the RPC error wire that pp.rpc
understands, and stamps every response with a request id. The server keeps
the real diagnostic; a production response never becomes the diagnostic channel.
Raise HttpError
From a page, layout, component, or @rpc() action,
raise HttpError instead of returning hand-built
error markup or JSON.
from casp.errors import HttpError
async def page(params: dict):
order = await find_order(params["id"])
if order is None:
raise HttpError.not_found("Order not found")
if order.locked:
raise HttpError(409, "This order is locked")
...
Shortcuts: bad_request, unauthorized,
forbidden, not_found,
conflict. The status must be 400–599.
details= attaches structured public context such as a field-error map,
and headers= adds headers like Retry-After.
Who may read the message
| Form | Server diagnostic | Production client message |
|---|---|---|
| HttpError(4xx, message) | message | message |
| HttpError(5xx, message) / HttpError.internal(message) | message | generic |
| HttpError.public(status, message) | message | message |
| HttpError.internal(diagnostic, public_message=text) | diagnostic | text |
| any other exception | str(exc) | generic |
The generic text is An unexpected error occurred. Development shows the
diagnostic so failures stay debuggable; production is decided by the fail-closed
APP_ENV resolution. Details follow the same rule and are
dropped for an unmarked 5xx in production.
Never put credentials, tokens, session payloads, connection strings, or
unapproved personal data in a public message or in details.
Nested error boundaries
An error.py is an error boundary for its folder. When a page
or a layout below that folder raises, the runtime renders the nearest ancestor boundary
with an ErrorInfo that is already safe for the client.
# src/app/dashboard/error.py
from casp.component_decorator import html
from casp.errors import ErrorInfo
def page(error: ErrorInfo):
return html(r"""
<section>
<h1>{{ error.status }} · {{ error.reason }}</h1>
<p>{{ error.message }}</p>
{% if error.request_id %}<small>Reference: {{ error.request_id }}</small>{% endif %}
</section>
""", error=error)
-
The nearest boundary wins;
src/app/error.pyis the root fallback. - Layouts at and above the boundary wrap the error page; layouts below it are skipped, because one of them may be what failed.
- A boundary that raises yields to its parent; with none left a minimal escaped page is served.
-
A 404 always renders the global
not_found.py. -
ErrorInfocarriesstatus,reason,code,message,request_id,details, andtrace(alwaysNonein production). -
Page requests always receive HTML, whatever their
Acceptheader.
RPC failures
Actions keep the PulsePoint wire so pp.rpc rejects normally.
The status is the HttpError status, mapping details are merged
beside error (never overwriting it or requestId),
PermissionError maps to 403 and ValueError to 400.
Anything else is a 500 with Internal server error.
{ "error": "Invalid input", "requestId": "84d9…", "errors": { "email": ["Required"] } }
Failures outside a page
Middleware and framework routes go through the global handlers: a browser document
request renders the root boundary, while an API client — including every
route.py endpoint — receives RFC 9457 Problem Details as
application/problem+json.
{ "type": "about:blank", "title": "Method Not Allowed", "status": 405, "detail": "Method Not Allowed", "requestId": "84d9…" }
Request correlation
RequestIdMiddleware is the outermost middleware, so every response
carries X-Request-Id. A valid incoming id (1–128 characters of
A-Z a-z 0-9 . _ : -) is preserved so a gateway and the app share one
id; set REQUEST_ID_TRUST_INCOMING=false to always mint a new one.
Error pages show it as error.request_id and RPC and Problem Details
bodies carry it as requestId. It correlates logs — it is not a
credential or a database key. Error responses are always Cache-Control: no-store.
from casp.observability import request_id rid = request_id() # None outside a request
error.py
The file convention for boundaries and the root fallback.
Observability
How 5xx failures are logged with their request id and traceback.