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, observability hooks - read Error Model. This chapter shows what to actually type.
The shape to remember:
- Handlers return
Response = Result<HttpResponse, HttpResponse>. - The
?operator collapsesFrameworkError,AppError,DbErr,ParamError,ValidationErrors, and any typedHttpErrorinto anHttpResponseautomatically. - Three free helpers (
abort_with,abort_if,abort_unless) let you short-circuit at a status code without naming an error type.
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 runs From<E> for HttpResponse. The
framework wires those impls so the things you actually call return
errors that already know how to render. You don't write the
conversion; you write the failure.
use ;
use EntityTrait;
pub async
Three things happen in that snippet - none of them are visible:
req.param("id")?→ParamError→FrameworkError::ParamError(400)..await?on a SeaORM call →DbErr→FrameworkError::Database(500, sanitised on the wire)..ok_or_else(...)?constructs aFrameworkError::ModelNotFounddirectly (404).
All three pass through the same From<FrameworkError> for HttpResponse
impl described in Error Model.
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, Unauthorized) keep their
variant so the response renderer still emits the right shape; 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.
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. This is Suprnova's equivalent of Laravel's
report() callback on the exception handler.
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 |
Next
- Error Model - variants, conversion contract, 5xx sanitisation, panic boundary
- Validation -
#[derive(Validate)], form requests, andafter_validation - Responses -
HttpResponsebuilders, status, headers - Events - listening to
ErrorOccurredand other built-in events - Request Lifecycle - where in the request flow the error conversion runs
