This is the day-to-day patterns guide for writing fallible code in Suprnova handlers, services, and middleware. For the underlying model - the conversion contract, the panic boundary, the 5xx sanitisation rule, the development error page, observability hooks - read Error Model. This chapter shows what to actually type.
The shape to remember:
- Handlers return
Response = Result<HttpResponse, HttpResponse>. ?performs one directFrom<E>conversion into the handler's error type; Rust does not chainDbErr -> FrameworkError -> HttpResponse. In aResponsehandler, convert a SeaORM error explicitly. Code that already returnsResult<_, FrameworkError>may use.await?directly.- Three free helpers (
abort_with,abort_if,abort_unless) let you short-circuit at a status code without naming an error type.
use EntityTrait;
use ;
pub async
The rest of the chapter is the catalogue of error producers - what to construct, what status it returns, what shape the client sees.
? is the conversion
Every ? in a handler body performs one direct
From<E> for HttpResponse conversion. The framework provides direct
conversions for its handler-facing error types, but Rust does not chain
multiple From implementations. Convert an intermediate error explicitly
when it has no direct conversion to HttpResponse.
use ;
use EntityTrait;
pub async
Four conversions happen in that snippet:
req.param("id")?directly convertsParamErrorto anHttpResponse(400).- The parse error is explicitly mapped to
FrameworkError::ParamError, which?then directly converts to anHttpResponse(400). - The SeaORM error is explicitly mapped from
DbErrtoFrameworkError::Database;?then directly converts thatFrameworkErrorto anHttpResponse(500, sanitised on the wire). .ok_or_else(...)?turnsNoneintoFrameworkError::ModelNotFound, which converts to anHttpResponse(404).
Each ? uses one direct conversion. Code that returns
Result<_, FrameworkError> instead of Response may use .await? on
the SeaORM call because DbErr converts directly to FrameworkError.
Every one of those conversions ends in the framework's JSON error body -
{ "message": …, "request_id": … } at the matching status. That is the
right answer for an API client and the wrong one for an Inertia visit,
which needs a page. Name an
error page and an Inertia
app renders these errors as a real page while API clients keep the JSON
unchanged. With debug mode on, a 5xx reaches a browser or an Inertia
visit as the development error page
instead.
AppError - inline domain errors
Use AppError for one-off errors that don't deserve a dedicated type.
The constructors map onto Laravel's abort($status, $msg) shape:
| Constructor | Status |
|---|---|
AppError::new(msg) |
500 |
AppError::bad_request(msg) |
400 |
AppError::unauthorized(msg) |
401 |
AppError::forbidden(msg) |
403 |
AppError::not_found(msg) |
404 |
AppError::conflict(msg) |
409 |
AppError::unprocessable(msg) |
422 |
AppError::new(msg).status(code) |
any |
AppError has a From into FrameworkError, so ? works with no
ceremony:
use ;
pub async
Note the asymmetry: AppError::unauthorized is 401 (missing
authentication credentials), while FrameworkError::Unauthorized is
403 (policy denied an authenticated user). They mean different
things; pick the one that matches the failure.
FrameworkError - the canonical enum
Internal extractors, the container, route binding, validation, the
database layer, and storage all produce FrameworkError. You usually
construct one through a convenience constructor and let ? route it.
use FrameworkError;
not_found; // 404
bad_request; // 400
param; // 400
param_parse; // 400
validation; // 422
domain; // 409 (any code)
internal; // 500
database; // 500
; // 500
model_not_found; // 404
The full variant set, with implications for the response shape, is in Error Model. The constructors above cover every common case; you reach for the variants directly only when matching on an error you received.
Automatic conversions
FrameworkError already speaks the dialects your dependencies emit.
Both of these ?s convert automatically:
use ;
use ActiveModelTrait;
pub async
The framework also implements From<opendal::Error> for storage
operations and From<ParamError> for path-parameter extraction.
Re-raising with context
When you want to annotate where an error came from without losing the
status code, use .context():
db.insert.await
.map_err
.map_err?;
The message becomes "creating new user: <original>". Structured
variants (Validation, ValidationError, ModelNotFound,
ParamParse, PrecognitionFailure, PrecognitionSuccess,
Unauthorized, UnsupportedMediaType, AlreadyReported,
RateLimited, Timeout, External) keep their variant so the response renderer
still emits the right shape (and, for External, so the wrapped
source survives); flat message-carrying variants (Internal,
Database, Domain) flatten into a Domain with the prefixed
message and the original status preserved.
Turning duplicate-key errors into 422
The Unique validation rule runs a SELECT COUNT(*) before the
write, so it's advisory - two concurrent requests can both pass and
then both attempt the insert. The losing request gets a database
unique-constraint violation, which would otherwise leak as a 500.
from_unique_violation translates it into the same 422 the advisory
rule would have produced:
use FrameworkError;
let user = new_user.insert.await.map_err?;
If the underlying DbErr isn't a unique-constraint violation it
passes through unchanged as a 500-class Database error. Backend
coverage is whatever SeaORM's DbErr::sql_err recognises - Postgres,
MySQL/MariaDB, and SQLite all map their duplicate-key errors through.
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. When APP_DEBUG=true, it also shows
the chain on the development error page for a browser or an
Inertia visit, and in the debug_message field of the JSON body for every other client, so a
wrapped error's text is never lost.
Preserving rate-limit hints
When a downstream service throttles you and supplies a Retry-After hint, wrapping the failure
in internal(...) melts the duration into prose. rate_limited keeps it structured:
use Duration;
use FrameworkError;
let err = rate_limited;
assert_eq!;
assert_eq!;
Queue retry policies, jitter scheduling, and the HTTP Retry-After response header all read the
hint back through retry_after(), which returns None for every other variant and for throttles
that came without one. .context(...) preserves the variant, so adding operation context does not
strip the duration.
Telling a passed deadline from a failure
FrameworkError::Timeout means a deadline passed before the awaited work
finished. FrameworkError::timeout(elapsed, message) builds one, and
is_timeout() asks for it. It renders as 504 Gateway Timeout, and the
client sees the generic 5xx message. The workflow wait returns it, so a
handler can tell a workflow that is still running from a failure:
use Duration;
use FrameworkError;
let err = timeout;
assert!;
assert_eq!;
See Error Model and Workflows.
Custom domain errors
Three tiers, depending on how reusable the error needs to be.
#[domain_error] for the typed case
Most reusable errors want a name, a fixed status, and a fixed message
template - no per-call message. The #[domain_error] attribute macro
generates Display, std::error::Error, HttpError, and From for
FrameworkError in one shot:
use domain_error;
;
Use them at the call site with ?:
use crateUserNotFound;
pub async
The macro rejects malformed attributes loudly at compile time -
overflowed status codes (status = 70_000), wrong literal types
(message = 42), unknown keys - so you can't silently get the wrong
status because of a typo.
Scaffold one with the CLI
Writes src/errors/user_not_found.rs with a default status = 500
and an inferred sentence-cased message, and updates src/errors/mod.rs
to re-export it. Edit the status and message to taste.
HttpError for the hand-rolled case
When a domain error needs runtime state in the message (e.g. the IDs
involved in the failure), implement HttpError directly. The trait
has two methods with sensible defaults:
use HttpError;
To bridge a hand-rolled HttpError into ?, call
FrameworkError::from_http_error. A blanket From<T: HttpError> for FrameworkError would conflict with the existing From<AppError>
impl, so the bridge is an explicit constructor:
account.withdraw
.map_err?;
Error enums for one module's failures
When a service has several related failures, group them in an enum
and write one From for the whole enum:
use FrameworkError;
use Error;
Once the From exists, the enum threads through ? the same as any
other error type.
abort_with / abort_if / abort_unless
Three helpers short-circuit a handler at a status. They mirror
Laravel's abort / abort_if / abort_unless. (The free function is
exported as abort_with rather than abort to keep the latter
available as a method name on user types.)
use ;
pub async
Each returns Result<(), FrameworkError>, so ? does the work. The
underlying error is FrameworkError::Domain { message, status_code },
which renders through the same body shape as every other error. Out-of-range
status codes are coerced to 500 by the response renderer; you don't need to
defend against bad input at the call site.
ValidationErrors - the Laravel-shaped error bag
When validation fails - at #[derive(Validate)] time or in an
after_validation body - the framework emits the JSON shape Laravel
and Inertia front-ends expect:
Most of the time you don't construct this directly - #[derive(Validate)]
runs and the framework converts validator::ValidationErrors for
you. When you need to add errors imperatively (cross-field rules, async
uniqueness checks that complement Unique), build a ValidationErrors
and return it:
use ;
pub async
add_to_bag scopes a field under a named bag (Laravel's
withErrors($errors, 'profile') shape) by prepending the bag with a
. separator. Useful when one response carries errors from multiple
sub-forms that can't share a flat namespace:
let mut errs = new;
errs.add_to_bag;
errs.add_to_bag;
// errors map: { "profile.bio": [...], "billing.card": [...] }
from_validator(ve) converts a validator::ValidationErrors;
retain_fields(&keep) returns a copy containing only the listed
entries (used by Precognition's Precognition-Validate-Only header
internally).
Hooking observability with ErrorOccurred
Every 5xx response fires an ErrorOccurred event - including the
ones synthesised from panics. Listen the same way you listen for any
event:
use Arc;
use ;
;
// In bootstrap.rs:
// `listen` infers both generics from the listener type. It returns
// `()` (the registration cannot fail), so no `?` and no Result.
.await;
The event carries the raw error message (the wire body is still
sanitised - see Error Model), the status, and the
correlatable request id. To run code for failed queue jobs and console
commands too, register a callback with Exceptions::reportable.
Report errors with Exceptions
Exceptions is the place every error the framework does not answer
itself goes through, as Laravel's exception handler is. These errors are
reported:
- Every
FrameworkErrorthat becomes a 5xx response.ErrorOccurredstill fires after it. - Every error behind a 5xx response the framework builds itself: a session the store cannot write, a Live or WebSocket request the framework cannot prepare or answer, a rate limiter or render cache that fails closed, a readiness probe whose database check fails, and a payment webhook that fails.
- Every failed attempt of a queued job: a handler that returns
Error panics, an attempt that runs past its timeout, and an errorFailOnExceptionfails the job for. - Every error a console command returns, and every failure of the boot
before it runs: a
bootstrapthat panics, such as one whoseDB::init().await.expect(..)finds no database, a log channel nothing defines, or a service the container cannot build. This covers the console binary's commands and the application binary's (queue:work,schedule:interrupt,downand the others). The callbacks yourbootstrapregistered before it failed see the failure. - Every error you pass to
Exceptions::report.
A report runs your callbacks in the order you registered them. Then it
writes the framework error log line, with the status, the full source
chain, and the request id when there is one. When the framework builds a
5xx for a failure it has already logged, such as a rate limiter that fails
closed, you get that line, with its own fields, in place of the
framework error line, so each failure is logged once. A callback with
stop() does not hold back that line.
Register callbacks once, in bootstrap.rs. The parameter type of the
closure decides which errors it receives:
use ;
;
A callback that takes &FrameworkError receives every error. A callback
that takes another error type E receives an error whose wrapped source
is an E: the error you passed to FrameworkError::from_external or
from_external_with. Only that wrapped error is compared, not the errors
in its own source() chain. To act on a deeper cause, take
&FrameworkError and walk std::error::Error::source yourself.
To report an error your code handles, and still have it recorded, pass it
to Exceptions::report:
use ;
Exceptions::dont_retry::<E>() and Exceptions::dont_retry_when name
the errors that end a queued job's retries. See
Queue.
Why Suprnova diverges
- A Laravel callback that returns
falsealso stops reporting. A Suprnova callback returns nothing, sostop()is the only way to stop. - An exception thrown from a Laravel callback ends the report. A Suprnova callback that panics is logged, and the report goes on to the next callback and the log line. A broken reporter cannot take a queue worker down.
Exceptions::reporttakes aFrameworkError. Wrap a foreign error withFrameworkError::from_externalfirst.
See a failure in the browser
With debug mode on, a route that fails with a 5xx shows the development
error page in a browser instead of a JSON body. Debug mode is on when
APP_DEBUG=true, and when APP_DEBUG is unset in the local,
development, or testing environment.
use ;
pub async
When the write fails, a browser that opens the route gets a 500 page that shows the following:
- The message
writing the ledger failed, and the I/O error it wraps. - The line in
write_ledgerthat calledfrom_external_with, and the stack frames of your code that led there. The frames of the framework, the async runtime, dependencies and the standard library are collapsed behind a count. You see the source line and a few lines around each application frame when its file is readable. You also see a separate trace for each source error that recorded frames. - The request's method, path, query, headers, route pattern, and request
id. Credentials and secret-named values are redacted, including the
secret parameters of a URL in a header such as
Referer.
An Inertia visit gets the same page, in place of your Inertia error
page. curl, and every other client whose Accept header does not list
text/html, still gets
{"message": "Internal Server Error", "request_id": "...", "debug_message": "..."}.
With debug off, you receive a minimal HTML error view for a 5xx when your
request accepts text/html and is not an Inertia visit. You see the
status with no error detail or frames. Your Inertia visits keep your app
error page, and your JSON requests keep their JSON body. The rules are in
Error Model.
Why Suprnova diverges
Laravel's exception page also shows the request body and runs a script for its "copy as Markdown" button. Suprnova's page does neither. A request body can hold the passwords and tokens the page redacts everywhere else, and a page that runs no script and loads nothing still renders when your frontend build is what broke.
Patterns you'll write a lot
Parse a path parameter as a typed value
let id: i64 = req.param?.parse
.map_err?;
ParamError already converts to 400; param_parse is the parse-failure
equivalent and renders the same shape.
Look up by ID, 404 on absent
let user = find_by_id
.one
.await
.map_err?
.ok_or_else?;
map_err(FrameworkError::from)? bridges the SeaORM DbErr through
From<DbErr> for FrameworkError and then through
From<FrameworkError> for HttpResponse. Rust does not auto-chain
From impls across two hops, so the explicit .map_err is required.
Or, with the Eloquent layer (which already wraps SeaORM and returns
Result<_, FrameworkError> directly):
use Model;
let user = find_or_fail.await?;
find_or_fail is find(id).ok_or(ModelNotFound) packaged up.
Authorize an action
let user = user.await?
.ok_or_else?;
abort_unless?;
abort_unless returns Result<(), FrameworkError>; the ? collapses
it back into your handler's error arm.
Service returning typed errors
use ;
;
// Call site:
pub async
App::resolve::<UserService>()? returns Result<Arc<UserService>, FrameworkError>. The chained ? collapses both the resolve failure
and the lookup failure to a response.
Cheat sheet
| You want… | Reach for |
|---|---|
| Inline error with a status | AppError::bad_request("…") and friends |
| Typed reusable error | #[domain_error(status = …, message = "…")] |
| Generated scaffold | suprnova make:error UserNotFound |
| Hand-rolled with runtime state | impl HttpError for MyError |
Bridge hand-rolled into ? |
FrameworkError::from_http_error(e) |
| Short-circuit at a status | abort_with / abort_if / abort_unless |
| 404 on missing model | FrameworkError::not_found("User") / Model::find_or_fail |
| Parse-failure on path param | FrameworkError::param_parse("id", "i64") |
| Field-level validation error | FrameworkError::validation("email", "…") |
| Multi-field error bag | ValidationErrors::new().add(…) + Validation(errs) |
| Duplicate-key violation → 422 | FrameworkError::from_unique_violation(field, msg, e) |
| Annotate an existing error | err.context("creating user") |
| Observe every 5xx | Listen for ErrorOccurred |
| Run code for every reported error | Exceptions::reportable(callback) |
| Record an error you handled | Exceptions::report(&err) |
| See a 5xx in the browser while developing | APP_DEBUG=true, then open the route |
| Render errors as an Inertia page | InertiaConfig::error_page("Error") |
Next
- Error Model - variants, conversion contract, 5xx sanitisation, panic boundary, development error page
- Validation -
#[derive(Validate)], form requests, andafter_validation - Responses -
HttpResponsebuilders, status, headers - Events - listening to
ErrorOccurredand other built-in events - Queue - retries, and the errors that end them
- Request Lifecycle - where in the request flow the error conversion runs
