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 |
FrameworkError and the framework's concrete error types use From
implementations. A hand-written HttpError must be mapped with
FrameworkError::from_http_error before ?; there is no blanket
From<T: HttpError> implementation. The middleware chain converts errors
at the request boundary, and the panic handler converts an unwind.
Ordinary errors then share the common body renderer and 5xx sanitisation
rule.
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 seventeen 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 Duration;
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
timeout; // → Timeout, 504
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, PrecognitionSuccess, Unauthorized,
ModelNotFound, ParamParse, UnsupportedMediaType,
AlreadyReported, RateLimited, Timeout, and External keep their structure
so the response renderer still emits the correct shape (and, for
External, so the wrapped source survives). Plain message-carrying
variants (Internal, Database, Domain) flatten into a Domain
with the prefixed message.
Wrapping a foreign error
Every other variant stringifies what it wraps. from_external_with keeps the original error
reachable, so logs can render the whole chain and code can still ask what actually failed:
use FrameworkError;
let row = sqlx_like_query
.await
.map_err?;
from_external(e) is the same thing with the error's own Display as the message. Both map to
HTTP 500.
To inspect the original, use external_source() rather than source():
if let Some = err.external_source
std::error::Error::source() hands back the shared Arc handle, not the wrapped error, so
downcasting through it returns None. external_source() dereferences the handle first.
The framework renders the full chain into the 5xx log line and into the debug_message field it
adds when APP_DEBUG=true, so a wrapped error's text is never lost.
Preserving rate-limit hints
RateLimited exists so a downstream Retry-After hint survives the trip through the error
system as a Duration instead of collapsing into message text:
use Duration;
use FrameworkError;
let err = rate_limited;
assert_eq!;
assert_eq!;
retry_after() returns None for every other variant and for throttles that arrived without a
hint. The variant renders as HTTP 429, and .context(...) preserves it rather than flattening to
Domain, so the duration is never stripped by adding operation context.
Telling a passed deadline from a failure
Timeout says that a deadline passed before the awaited work finished. It
carries the deadline as elapsed and what was awaited as message. The
workflow wait returns it: WorkflowHandle::wait_with_timeout and
wait_with_options end with FrameworkError::Timeout when the workflow is
still pending or running at the deadline (see
Workflows). A failed status query is a
different error, so a caller can match the two apart:
use Duration;
use FrameworkError;
let err = timeout;
assert!;
assert_eq!;
assert_eq!;
The error does not cancel the work. It renders as HTTP 504, and like every
5xx the client sees the generic Internal Server Error message while the
detail goes to the logs. .context(...) preserves the variant, so a caller
further up can still match on it.
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 ordinary body shape
Ordinary error responses that reach the common renderer follow this JSON skeleton:
messageis always present on these ordinary responses.errorsonly appears for validation-style errors (Validation,ValidationError) - both render the same shape so consumers parse one path.request_idappears on these ordinary responses (nullwhen outside a request scope, such as 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.
Three special variants return before request-id injection:
PrecognitionSuccessis a bodyless 204 response.PrecognitionFailurecontains the validation body plus Precognition headers.- An accidentally HTTP-rendered
AlreadyReportedsentinel is a generic 500 response containing onlymessage.
The 5xx sanitisation rule
This is the safety guarantee worth memorising. For any error with
status ≥ 500 that reaches the common renderer, 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.
Rendering the full chain: render_error_chain
thiserror's generated Display prints only an error's own message,
so a FrameworkError::External's wrapped source is invisible unless
something walks the chain. render_error_chain does that walk and
joins the result with ": ", the same separator .context() uses -
the framework calls it before building error_message above and
before the matching 5xx log line, which is why a wrapped error doesn't
lose its cause in either place.
Reach for it yourself when a listener or a log sink needs the same
full-chain rendering, for example re-wrapping error_message before
forwarding it to a sink that only takes a flat string:
use render_error_chain;
let chain = render_error_chain;
// "loading users: connection refused (os error 111)"
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 containing only
{"message": "Internal Server Error"}. 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 common renderer replaces the wire
messagefor any 5xx withInternal Server Error; raw detail flows to logs andErrorOccurred. An accidentally HTTP-renderedAlreadyReportedsentinel returns the same generic message withoutrequest_id. - Optional debug visibility.
APP_DEBUG=trueadds adebug_messagefield for ordinary 5xx responses, nevermessage. Production clients cannot accidentally couple to dev-only data. - Correlatable request ids. Every ordinary error body that reaches the
common renderer carries the request id (or
nullwhen no request scope exists); the same id appears in the log line and theErrorOccurredevent. The three early-return variants described above bypass this field. - 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 common shape for ordinary errors. Validation errors, parameter errors, panics, custom domain errors, and storage failures that reach the common renderer use the same JSON skeleton. The three special variants documented above have distinct wire shapes.
Where each piece lives
| Piece | File |
|---|---|
FrameworkError, AppError, HttpError, ValidationErrors |
framework/src/error.rs |
render_error_chain |
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
