This chapter is the model underneath Suprnova's error handling - the
types, the conversion contract, and the safety guarantees the framework
gives you for free. For day-to-day handler patterns (?, returning
errors, building custom domain errors) see Error Handling;
this chapter explains why those patterns work the way they do.
If you remember one thing from this page: errors in Suprnova are
values, not exceptions. Every error eventually becomes an
HttpResponse via a single, total conversion. There is no global
exception handler because there is no global exception.
The shape
Suprnova's error model has five moving parts:
| Type | Role |
|---|---|
Response = Result<HttpResponse, HttpResponse> |
The contract every handler satisfies - both arms are already responses |
FrameworkError |
The framework's canonical error enum; every internal error path produces one |
AppError |
Ad-hoc domain error for inline use without a dedicated type |
HttpError (trait) |
What your own typed domain errors implement to get a status + message |
ValidationErrors |
The Laravel/Inertia-shaped error bag for per-field failures |
All five collapse to a single HttpResponse through From impls. The
? operator does the conversion at the call site; the middleware
chain does it at the request boundary; the panic handler does it when
something unwound. There is one body shape for everything, and one
sanitisation rule for 5xx.
Response is Result<HttpResponse, HttpResponse>
Every handler returns this:
pub type Response = ;
Both arms carry the same payload type, which is the whole point. When the middleware chain finishes executing your handler it collapses the result with one line:
result.unwrap_or_else
The framework does not need to know whether your handler "succeeded"
or "failed" - both arms are already rendered HTTP responses. The
distinction exists only so ? can do its job:
use ;
pub async
That single contract - every error path produces an HttpResponse
through From - is the core of the model. Everything else in this
chapter is what the various From impls actually do.
Why Suprnova diverges
Laravel throws exceptions and routes them through a global Handler
class registered in app/Exceptions/Handler.php. The framework
catches everything, asks the handler "what do I render?", and emits
the response. PHP's unwinding-exception model makes this natural.
Rust has no unwinding exceptions in user code. Suprnova's equivalent
is the From<FrameworkError> for HttpResponse impl plus the
ErrorOccurred event. The conversion is the renderer; the event is
where you hook observability (Sentry, PagerDuty, structured shippers).
You don't register a handler class - the conversion is a function and
listening for ErrorOccurred is the extension point. Same surface,
different machinery.
FrameworkError - the canonical enum
Every error path inside the framework - extractors, route binding,
the container, validation, the database layer, storage - produces a
FrameworkError. It's an enum with fourteen variants, each tagged
with its HTTP status:
You rarely match on the variant. You construct one through a
convenience constructor and let ? do the rest:
use FrameworkError;
// All of these produce a FrameworkError with the right status:
not_found; // → ModelNotFound, 404
bad_request; // → Domain, 400
param; // → ParamError, 400
param_parse; // → ParamParse, 400
validation; // → ValidationError, 422
domain; // → Domain, 409
internal; // → Internal, 500
database; // → Database, 500
There are no unauthorized() or forbidden() constructors on
FrameworkError - Unauthorized is a fixed variant carrying the
Laravel "This action is unauthorized." message at 403, and 401 cases
go through AppError::unauthorized (next section). Note: the variant
is named Unauthorized but the status is 403 because it models
Laravel's authorization rejection, not HTTP authentication.
Automatic conversion
FrameworkError implements From<sea_orm::DbErr> and
From<opendal::Error> so database and storage errors flow through ?
without a wrap:
use ;
use ActiveModelTrait;
pub async
If your code returns Result<_, FrameworkError>, every common error
your dependencies produce already speaks the right language. The
controller's ? does no work beyond converting one error type into
another.
Wrapping context
When you need to re-raise an error with operation context, use
.context():
db.insert.await
.map_err
.map_err?;
The message becomes "creating new user: <original>". The variant is
preserved where it matters - Validation, ValidationError,
PrecognitionFailure, Unauthorized, ModelNotFound, and
ParamParse keep their structure so the response renderer still emits
the correct shape. Plain message-carrying variants (Internal,
Database, Domain) flatten into a Domain with the prefixed
message.
AppError - ad-hoc domain errors
For one-off errors where you don't want to define a dedicated type,
use AppError. It implements HttpError and has a From into
FrameworkError, so ? works directly:
use ;
pub async
The constructors map cleanly onto Laravel's abort($status, $msg)
shape:
AppError::* |
Status |
|---|---|
bad_request(msg) |
400 |
unauthorized(msg) |
401 |
forbidden(msg) |
403 |
not_found(msg) |
404 |
conflict(msg) |
409 |
unprocessable(msg) |
422 |
new(msg) |
500 |
.status(code) |
any |
Note AppError::unauthorized is 401 (HTTP authentication missing),
while FrameworkError::Unauthorized is 403 (authorization denied,
matching Laravel's policy rejection). They mean different things; pick
the one that matches the failure.
HttpError - custom typed errors
When the same domain error appears in many places, model it as a
type. Implement HttpError and the conversion is yours:
use HttpError;
HttpError has two methods, both with defaults:
Bridging to ?
A naive impl<T: HttpError> From<T> for FrameworkError would conflict
with the existing From<AppError> impl (because AppError itself
implements HttpError). Suprnova resolves the orphan-rule problem
with an explicit bridge constructor instead:
use ;
pub async
The status code and message are taken from HttpError::status_code
and HttpError::error_message and stored in a FrameworkError::Domain
variant. The response renderer then follows the normal Domain path.
#[domain_error] for boilerplate-free types
If you want the typed-error pattern without writing the Display,
Error, and HttpError impls by hand, use the #[domain_error]
attribute macro:
use domain_error;
;
#[domain_error] generates the full impl set including
From<YourError> for FrameworkError, so ? works directly with no
bridge call:
pub async
The three tiers of custom error story - AppError for inline,
#[domain_error] for typed-with-macro, hand-rolled HttpError for
full control - give you the right tool at every level of formality.
ValidationErrors - the Laravel-shaped error bag
When a request fails validation, Suprnova emits the same JSON shape Laravel and Inertia front-ends expect:
You usually don't build this by hand - #[derive(Validate)] on a
form request and the validator crate behind it produces a
validator::ValidationErrors which Suprnova converts via
ValidationErrors::from_validator. But the type is public when you
need it:
use ;
pub async
add_to_bag scopes errors under a named bag (Laravel's
withErrors($errors, 'profile') shape) by prepending the bag name
with a . separator:
let mut errs = new;
errs.add_to_bag;
errs.add_to_bag;
// errors map: { "profile.bio": [...], "billing.card": [...] }
retain_fields keeps only the listed entries - used internally by
Precognition's Precognition-Validate-Only header so the server runs
full validation but reports errors only for the fields the client
asked about.
The conversion contract
When a FrameworkError reaches an HTTP boundary it goes through
From<FrameworkError> for HttpResponse. Three things happen, in
order:
- Status routing. The variant's
status_code()is read once. - Logging + observability. 5xx fires
tracing::error!and dispatchesErrorOccurred; 4xx firestracing::warn!. Both carry the request id when one is in scope. - Body rendering. A JSON body in the Laravel shape, sanitised for 5xx.
The body shape
All error bodies follow the same JSON skeleton:
messageis always present.errorsonly appears for validation-style errors (Validation,ValidationError) - both render the same shape so consumers parse one path.request_idalways appears (nullwhen outside a request scope - e.g. during early boot or in tests with no request context).debug_messageonly appears for 5xx whenAPP_DEBUG=true. It is strictly additive - production clients must not key on it.
The 5xx sanitisation rule
This is the safety guarantee worth memorising. For any error with
status ≥ 500, the JSON body's message is replaced with the literal
string:
The raw error detail does not leak to the response body. It goes to:
- the
tracing::error!log entry, with the request id and status - the
ErrorOccurredevent, which any listener can pick up
When APP_DEBUG=true (false by default outside local/dev/test),
the response also carries a debug_message field with the raw detail -
but message stays generic in both modes, so frontends and clients
can't accidentally couple to dev-only data.
This is the contract that lets you call FrameworkError::internal("db connection refused: password mismatch on user 'app_rw'") without
leaking the password to the wire. The message you pass is for
operators reading logs; the message the client sees is "Internal Server Error".
For 4xx errors, the caller-facing message is preserved - 404 User not found, 400 Missing required parameter: user_id. These are
domain errors the client needs to act on, not internal failures.
Where the contract lives
The whole conversion is one function - impl From<FrameworkError> for HttpResponse in
framework/src/http/response.rs. Read it once and you've read the
entire error rendering surface of Suprnova. There is no other path.
The panic boundary
A panic in a middleware or handler would otherwise propagate up the per-connection task and tear down the hyper service mid-response, leaving the client with a TCP reset and no HTTP response. Suprnova catches it.
execute_chain_safely in framework/src/server.rs wraps the
middleware chain in AssertUnwindSafe(...).catch_unwind().await. On
a panic it:
- Extracts the panic payload (handles
&'static strandStringpayloads; anything else surfaces as"panic with non-string payload"). - Logs
tracing::error!with the request method, path, and id. - Constructs
FrameworkError::internal(format!("request handler panicked: {msg}"))and routes it through the sameFrom<FrameworkError> for HttpResponseconversion every other 5xx uses. - Echoes the request id back as
X-Request-Id.
The panic payload stays in the log entry; the client gets the
sanitised {"message": "Internal Server Error"} body. Observability
listeners that fire on ErrorOccurred for returned 5xx errors also
fire on panics - there is no separate panic-event surface to wire up.
The same panic-recovery pattern is used by:
- WebSocket handlers (
framework/src/server.rs) - Scheduled tasks (
framework/src/schedule/mod.rs) - Workflows (
framework/src/workflow/mod.rs) - The
Supervisortrait (broadcasting)
A panic in one of these subsystems is logged and either translated to an error state or auto-restarted; it does not bring down the worker task.
Hooking observability with ErrorOccurred
ErrorOccurred is a built-in event the framework dispatches on every
5xx response (including the ones synthesised from panics):
Listen for it the same way you listen for any event:
use Arc;
use ;
;
// In bootstrap.rs:
.await;
This is the Suprnova equivalent of Laravel's report() callback on
the global exception handler. The event arrives with the original
unsanitised error_message (the body the client sees is still
sanitised), the status code, and the correlatable request id.
Abort helpers
Three free functions short-circuit a handler at a given status. They
mirror Laravel's abort / abort_if / abort_unless:
use ;
pub async
Each returns Result<(), FrameworkError>. Use them with ?. The
underlying error is FrameworkError::Domain { message, status_code },
so it renders through the same body shape and sanitisation rules as
every other error. Out-of-range status codes are coerced to 500 by
the response renderer's status validation; you don't need to defend
against bad input at the call site.
The CLI sentinel: AlreadyReported
One variant of FrameworkError has no HTTP meaning. AlreadyReported
is constructed via FrameworkError::silent() and used by the console
dispatcher when clap has already formatted and printed its own
argument-parse error. The binary's main translates the sentinel to
a non-zero exit code without eprintln, so users never see two error
messages for the same failure.
If AlreadyReported ever reaches an HTTP response converter, it
indicates a request handler accidentally returned silent(). The
converter logs a loud tracing::error! identifying the leak and
returns a generic 500 - the variant has no business in the request
path, and the loud log makes the bug observable instead of silent.
You don't normally see this variant; it's documented here because the
enum is HTTP-flavoured and the otherwise-unexplained variant would
puzzle anyone reading the source.
Safety guarantees, in summary
The contract Suprnova gives you:
- Total conversion. Every
FrameworkErrorproduces anHttpResponse. There is no error path that crashes the server or drops the connection silently. - Sanitised 5xx. The wire body for any 5xx is the generic
{"message": "Internal Server Error", "request_id": "..."}. Detail flows to logs +ErrorOccurred. - Optional debug visibility.
APP_DEBUG=trueadds adebug_messagefield for 5xx, nevermessage. Production clients cannot accidentally couple to dev-only data. - Correlatable request ids. Every error body carries the request
id (or
nullwhen no request scope exists); the same id appears in the log line and theErrorOccurredevent. - Panic recovery. Panics in handlers and middleware are caught,
logged, and routed through the same
Fromimpl as returned errors. No connection drop, no observability gap. - One shape for everything. Validation errors, parameter errors, panics, custom domain errors, and storage failures all collapse to the same JSON skeleton. Frontend code parses one structure.
Where each piece lives
| Piece | File |
|---|---|
FrameworkError, AppError, HttpError, ValidationErrors |
framework/src/error.rs |
From<FrameworkError> for HttpResponse (conversion + sanitisation) |
framework/src/http/response.rs |
abort, abort_if, abort_unless |
framework/src/http/abort.rs |
execute_chain_safely (panic boundary) |
framework/src/server.rs |
ErrorOccurred event |
framework/src/events/builtins.rs |
#[domain_error] macro |
suprnova-macros/src/domain_error.rs |
Next
- Error Handling - the practical handler patterns that use this model
- Request Lifecycle - where in the request flow the error conversion runs
- Validation -
#[derive(Validate)], form requests, and howValidationErrorsgets populated - Responses -
HttpResponsebuilders, headers, cookies, streaming - Events - listening to
ErrorOccurredand other built-in events
