Manual contentsThe BasicsBrowse 103 chapters
Manual 15 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>.
  • The ? operator collapses FrameworkError, AppError, DbErr, ParamError, ValidationErrors, and any typed HttpError into an HttpResponse automatically.
  • Three free helpers (abort_with, abort_if, abort_unless) let you short-circuit at a status code without naming an error type.
use suprnova::{Request, Response, json_response};

pub async fn show(req: Request) -> Response {
    let id = req.param("id")?;          // 400 if missing
    let user = find_user(id).await?;    // 500 on DbErr, 404 on Option::None
    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 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 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?
        .ok_or_else(|| FrameworkError::not_found("User"))?;

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

Three things happen in that snippet - none of them are visible:

  1. req.param("id")?ParamErrorFrameworkError::ParamError (400).
  2. .await? on a SeaORM call → DbErrFrameworkError::Database (500, sanitised on the wire).
  3. .ok_or_else(...)? constructs a FrameworkError::ModelNotFound directly (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 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, 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 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.

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

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