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, Denial,
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. With debug on, a browser or an Inertia visit gets the development error page in place of this body.
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. A browser or an Inertia visit
gets the development error page instead,
which shows the same detail as HTML.
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. With debug on, a
browser or an Inertia visit gets the
development error page instead, with the
panic message and the line that panicked. 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.
The development error page
With debug mode on, a failed request is hard to read as a
debug_message string in a browser tab. So for a browser or an Inertia
visit, Suprnova replaces a 5xx built from a failure with the development
error page: one HTML document that shows what failed, where, and on
which request.
When it renders
The server replaces a response with the page when all of these hold:
Config::is_debug()returnstrue:APP_DEBUG=true, orAPP_DEBUGunset in thelocal,development, ortestingenvironment.- The status is 500 or above.
- The response carries an
ErrorReport: aFrameworkErrorbecame the response, the panic boundary caught a panic, or one of the framework's middleware answered a failure with a 5xx. - The request is an Inertia visit (
X-Inertia: true), or itsAcceptheader liststext/html.
Everything else stays as it is. A 4xx keeps its body, and so does a 5xx
your handler built itself without an error. A client whose Accept
header doesn't list text/html (application/json, */*, or no
Accept at all) and isn't an Inertia visit keeps the JSON body with
debug_message. With debug off, you receive a minimal HTML view for a
5xx when your request accepts text/html and is not an Inertia visit. You
see its status without error details or frames. Your Inertia visits keep
the application error page, and your JSON requests keep their JSON body.
No frames or request details are recorded for this production view.
The page is built in the server, after the panic boundary, so it covers
panics as well as returned errors. For these responses it takes the
place of the app's Inertia error page. It keeps the status, the error
report, Set-Cookie, X-Request-Id, and the other headers that describe
the request, and it replaces the ones that described the old body.
To see it, run the app with APP_DEBUG=true and open a route that fails
in a browser. From a terminal, ask for HTML:
What it shows
- The type of what failed, above the headline, as Laravel's page shows
the exception class. An error built with
FrameworkError::from_externalorfrom_external_withis named by the type it wraps, asstd::any::type_namespells it:std::io::error::Errorfor anstd::io::Error. Any otherFrameworkErroris named by its variant, such asFrameworkError::ModelNotFound, and a panic is namedpanic.ErrorReport::type_name()returns the same name. - The error chain, one line per source, or the panic message and the
file:line:columnwhere the panic was raised. - The site that created the error: the line of the call to a
FrameworkErrororAppErrorconstructor, or of a?that converts the error. - The stack frames recorded at that site. Your application's frames are listed: your handlers and middleware, and the code they call. Each run of frames from the framework, the async runtime, other dependencies, and the standard library is collapsed behind a count. You see the source line and a few nearby lines for every application frame whose source file is readable. Each source error with recorded frames has its own trace.
- The request: the method, the path, the query parameters, the headers, the matched route pattern, and the request id.
Frames are recorded for an error created on the request's own task by a
FrameworkError or AppError constructor or by a From conversion,
which includes ?. An error built as a struct literal, or created on a
task the request spawned, has no frames, and the page says so.
The constructors and conversions are #[track_caller], so the site is
the line that called them, even in a build without debug info. A call
made inside a function that is not #[track_caller] reports that
function's line instead: with .map_err(FrameworkError::from)?, the site
is a line of Result::map_err in the Rust toolchain, and the frames show
the handler that called it. A build without debug info names each
frame's function but not its line. The frame that created the error is
the exception: it shows the creation site as its location.
Open a frame in your editor
Set APP_EDITOR and every frame with a location links to its line in
your editor, as Laravel's app.editor does:
APP_EDITOR=vscode
- A name Laravel knows uses that editor's URL format.
vscodelinks tovscode://file/{file}:{line}andphpstormtophpstorm://open?file={file}&line={line}. The other names areantigravity,atom,cursor,emacs,fleet,idea,kiro,macvim,neovim,netbeans,nova,sublime,textmate,trae,vscode-insiders,vscode-insiders-remote,vscode-remote,vscodium,windsurf,xdebug, andzed. - A value that holds
{file}is a template:APP_EDITOR=myeditor://{file}#{line}. - Any other value
namelinks toname://open?file={file}&line={line}.
{file} is the absolute path of the frame's file. A relative location
is resolved the way the page finds source lines: from the working
directory first. {line} is the frame's line. Without APP_EDITOR, no
frame carries a link.
What it never shows
- The values of the
Authorization,Proxy-Authorization,Cookie, andSet-Cookieheaders, and of every header and query parameter whose name containstoken,secret,password,key, orsignaturein any letter case. Each value reads[redacted]. - The value of every such parameter anywhere else in the text the page
shows: in a
Refereror anX-Original-URIheader that repeats the request's URL,/reset?token=...reads/reset?token=[redacted]. - The password of a URL with credentials:
postgres://app:hunter2@db/appreadspostgres://app:[redacted]@db/app. - The request body, environment variables, and configuration values. The page never reads them.
What it loads
Nothing. The page is one HTML document with an inline style sheet and no
JavaScript. It requests no script, style sheet, font, image, or frame,
so it renders when the frontend build, the Vite manifest, Inertia, or the
view layer is what failed. Every value it shows is HTML-escaped. The
response carries Cache-Control: no-store and a
Content-Security-Policy of default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none', which allows no
script.
Why Suprnova diverges
Laravel's exception page shows the request body, lists the SQL queries the request ran, and runs a bundled JavaScript app that adds a "copy as Markdown" button. Suprnova's page differs on all three:
- It never shows the request body. A body often holds the passwords, tokens, and personal data the header redaction exists to protect, and a development server on a shared network or behind a tunnel shows the page to whoever triggers the error.
- It runs no script and loads nothing, so it renders in the situations where a developer needs it most: a broken frontend build, a missing Vite manifest, or a failure inside Inertia.
- It lists no queries. SQL debugging is separate work, outside this page.
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. A browser or an Inertia visit gets the development error page instead, which redacts credentials and never shows the request body, environment variables, or configuration values. - 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 |
| The development error page | framework/src/error/debug_page.rs |
| Stack frame recording and classification | framework/src/error/frames.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
