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>. ?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.
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 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
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. 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 |
| Render errors as an Inertia page | InertiaConfig::error_page("Error") |
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
