Manual contentsThe BasicsBrowse 113 chapters
Manual 18 min read

Error Handling

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 direct From<E> conversion into the handler's error type; Rust does not chain DbErr -> FrameworkError -> HttpResponse. In a Response handler, convert a SeaORM error explicitly. Code that already returns Result<_, 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 sea_orm::EntityTrait;
use suprnova::{DB, FrameworkError, Request, Response, json_response};

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| FrameworkError::param_parse("id", "i64"))?;
    let user = users::Entity::find_by_id(id)
        .one(&*DB::get()?)
        .await
        .map_err(FrameworkError::from)?
        .ok_or_else(|| FrameworkError::not_found("User"))?;
    json_response!({ "user": user })
}

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 suprnova::{DB, FrameworkError, Request, Response, json_response};
use sea_orm::EntityTrait;

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| FrameworkError::param_parse("id", "i64"))?;

    let user = users::Entity::find_by_id(id)
        .one(&*DB::get()?)
        .await
        .map_err(FrameworkError::from)?
        .ok_or_else(|| FrameworkError::not_found("User"))?;

    json_response!({ "user": user })
}

Four conversions happen in that snippet:

  1. req.param("id")? directly converts ParamError to an HttpResponse (400).
  2. The parse error is explicitly mapped to FrameworkError::ParamError, which ? then directly converts to an HttpResponse (400).
  3. The SeaORM error is explicitly mapped from DbErr to FrameworkError::Database; ? then directly converts that FrameworkError to an HttpResponse (500, sanitised on the wire).
  4. .ok_or_else(...)? turns None into FrameworkError::ModelNotFound, which converts to an HttpResponse (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 suprnova::{AppError, Request, Response, json_response};

pub async fn transfer(req: Request) -> Response {
    let amount: i64 = req.param("amount")?.parse()
        .map_err(|_| AppError::bad_request("amount must be a number"))?;

    if amount <= 0 {
        return Err(AppError::unprocessable("amount must be positive").into());
    }

    if amount > balance() {
        return Err(AppError::forbidden("amount exceeds daily limit").into());
    }

    json_response!({ "transferred": amount })
}

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 suprnova::FrameworkError;

FrameworkError::not_found("User");                    // 404
FrameworkError::bad_request("Bad input");             // 400
FrameworkError::param("user_id");                     // 400
FrameworkError::param_parse("user_id", "i64");        // 400
FrameworkError::validation("email", "required");      // 422
FrameworkError::domain("Conflict", 409);              // 409 (any code)
FrameworkError::internal("disk full");                // 500
FrameworkError::database("timeout");                  // 500
FrameworkError::service_not_found::<MyService>();     // 500
FrameworkError::model_not_found("Post");              // 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 suprnova::{DB, FrameworkError};
use sea_orm::ActiveModelTrait;

pub async fn create_user(new_user: users::ActiveModel)
    -> Result<users::Model, FrameworkError>
{
    // DB::get returns Result<_, FrameworkError>.
    // .insert returns Result<_, DbErr>, with From<DbErr> for FrameworkError.
    let user = new_user.insert(&*DB::get()?).await?;
    Ok(user)
}

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(user).await
    .map_err(FrameworkError::from)
    .map_err(|e| e.context("creating new user"))?;

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 suprnova::FrameworkError;

let user = new_user.insert(db).await.map_err(|e| {
    FrameworkError::from_unique_violation(
        "email",
        "That email address is already registered.",
        e,
    )
})?;

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 suprnova::FrameworkError;

let row = sqlx_like_query()
    .await
    .map_err(|e| FrameworkError::from_external_with("verify query failed", e))?;

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(src) = err.external_source() {
    if let Some(db) = src.downcast_ref::<sea_orm::DbErr>() {
        // decide whether this is worth retrying
    }
}

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 std::time::Duration;
use suprnova::FrameworkError;

let err = FrameworkError::rate_limited(
    Some(Duration::from_secs(30)),
    "push provider rejected the batch",
);

assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
assert_eq!(err.status_code(), 429);

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 std::time::Duration;
use suprnova::FrameworkError;

let err = FrameworkError::timeout(Duration::from_secs(30), "waiting for the export");

assert!(err.is_timeout());
assert_eq!(err.status_code(), 504);

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 suprnova::domain_error;

#[domain_error(status = 404, message = "User not found")]
pub struct UserNotFound;

#[domain_error(status = 402, message = "Insufficient funds")]
pub struct InsufficientFunds {
    pub available: i64,
    pub requested: i64,
}

Use them at the call site with ?:

use crate::errors::user_not_found::UserNotFound;

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| FrameworkError::param_parse("id", "i64"))?;

    let user = find_user(id).await
        .ok_or_else(|| FrameworkError::from(UserNotFound))?;

    json_response!({ "user": user })
}

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

suprnova make:error UserNotFound

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 suprnova::HttpError;

#[derive(Debug)]
pub struct InsufficientFunds {
    pub available: i64,
    pub requested: i64,
}

impl std::fmt::Display for InsufficientFunds {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Insufficient funds: have {}, need {}",
            self.available, self.requested)
    }
}

impl std::error::Error for InsufficientFunds {}

impl HttpError for InsufficientFunds {
    fn status_code(&self) -> u16 { 402 }
    fn error_message(&self) -> String {
        format!("Need {} units, only {} available.",
            self.requested, self.available)
    }
}

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(amount)
    .map_err(FrameworkError::from_http_error)?;

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 suprnova::FrameworkError;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum OrderError {
    #[error("Order {0} not found")]
    NotFound(i64),

    #[error("Insufficient stock for product {product_id}")]
    InsufficientStock { product_id: i64 },

    #[error("Payment failed: {0}")]
    PaymentFailed(String),

    #[error("Order already shipped")]
    AlreadyShipped,
}

impl From<OrderError> for FrameworkError {
    fn from(err: OrderError) -> Self {
        let status = match &err {
            OrderError::NotFound(_) => 404,
            OrderError::InsufficientStock { .. } => 422,
            OrderError::PaymentFailed(_) => 402,
            OrderError::AlreadyShipped => 409,
        };
        FrameworkError::Domain {
            message: err.to_string(),
            status_code: status,
        }
    }
}

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 suprnova::{abort_if, abort_unless, abort_with, Request, Response, json_response};

pub async fn show(req: Request) -> Response {
    abort_unless(Auth::user().await?.is_some(), 401, "must be logged in")?;
    abort_if(req.param("id")? == "0", 404, "User not found")?;
    abort_with(503, "scheduled maintenance")?;

    json_response!({ "ok": true })
}

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:

{
    "message": "The given data was invalid.",
    "errors": {
        "email": ["The email field must be a valid email address."],
        "password": ["The password field must be at least 8 characters."]
    },
    "request_id": "8f9e1a2b-c3d4-..."
}

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 suprnova::{FrameworkError, ValidationErrors};

pub async fn after_validation(payload: &Signup) -> Result<(), FrameworkError> {
    let mut errs = ValidationErrors::new();

    if payload.email.ends_with("@example.com") {
        errs.add("email", "example.com addresses are not allowed");
    }
    if payload.password == payload.email {
        errs.add("password", "password must not match email");
    }

    errs.into_result().map_err(FrameworkError::Validation)
}

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 = ValidationErrors::new();
errs.add_to_bag("profile", "bio", "must be under 280 characters");
errs.add_to_bag("billing", "card", "expired");
// 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 std::sync::Arc;
use suprnova::{ErrorOccurred, EventFacade, FrameworkError, Listener};

pub struct SentryReporter;

#[suprnova::async_trait]
impl Listener<ErrorOccurred> for SentryReporter {
    async fn handle(&self, evt: &ErrorOccurred) -> Result<(), FrameworkError> {
        sentry::capture_message(&evt.error_message, sentry::Level::Error);
        Ok(())
    }
}

// In bootstrap.rs:
// `listen` infers both generics from the listener type. It returns
// `()` (the registration cannot fail), so no `?` and no Result.
EventFacade::listen::<ErrorOccurred, SentryReporter>(Arc::new(SentryReporter)).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("id")?.parse()
    .map_err(|_| FrameworkError::param_parse("id", "i64"))?;

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 = users::Entity::find_by_id(id)
    .one(&*DB::get()?)
    .await
    .map_err(FrameworkError::from)?
    .ok_or_else(|| FrameworkError::not_found("User"))?;

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 suprnova::Model;

let user = User::find_or_fail(id).await?;

find_or_fail is find(id).ok_or(ModelNotFound) packaged up.

Authorize an action

let user = Auth::user().await?
    .ok_or_else(|| AppError::unauthorized("login required"))?;
abort_unless(post.owner_id == user.id() || user.is_admin(), 403,
    "you don't own this post")?;

abort_unless returns Result<(), FrameworkError>; the ? collapses it back into your handler's error arm.

Service returning typed errors

use suprnova::{App, FrameworkError, injectable};

#[injectable]
pub struct UserService;

impl UserService {
    pub async fn find_by_email(&self, email: &str)
        -> Result<users::Model, FrameworkError>
    {
        users::Entity::find()
            .filter(users::Column::Email.eq(email))
            .one(&*DB::get()?)
            .await?
            .ok_or_else(|| FrameworkError::not_found("User"))
    }
}

// Call site:
pub async fn show(req: Request) -> Response {
    let email = req.param("email")?;
    let user = App::resolve::<UserService>()?
        .find_by_email(email)
        .await?;
    json_response!({ "user": user })
}

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, and after_validation
  • Responses - HttpResponse builders, status, headers
  • Events - listening to ErrorOccurred and other built-in events
  • Request Lifecycle - where in the request flow the error conversion runs