Manual contentsArchitecture ConceptsBrowse 103 chapters
Manual 16 min read

Error Model

This chapter is the model underneath Suprnova's error handling - the types, the conversion contract, and the safety guarantees the framework gives you for free. For day-to-day handler patterns (?, returning errors, building custom domain errors) see Error Handling; this chapter explains why those patterns work the way they do.

If you remember one thing from this page: errors in Suprnova are values, not exceptions. Every error eventually becomes an HttpResponse via a single, total conversion. There is no global exception handler because there is no global exception.

The shape

Suprnova's error model has five moving parts:

Type Role
Response = Result<HttpResponse, HttpResponse> The contract every handler satisfies - both arms are already responses
FrameworkError The framework's canonical error enum; every internal error path produces one
AppError Ad-hoc domain error for inline use without a dedicated type
HttpError (trait) What your own typed domain errors implement to get a status + message
ValidationErrors The Laravel/Inertia-shaped error bag for per-field failures

All five collapse to a single HttpResponse through From impls. The ? operator does the conversion at the call site; the middleware chain does it at the request boundary; the panic handler does it when something unwound. There is one body shape for everything, and one sanitisation rule for 5xx.

Response is Result<HttpResponse, HttpResponse>

Every handler returns this:

pub type Response = Result<HttpResponse, HttpResponse>;

Both arms carry the same payload type, which is the whole point. When the middleware chain finishes executing your handler it collapses the result with one line:

result.unwrap_or_else(|e| e)

The framework does not need to know whether your handler "succeeded" or "failed" - both arms are already rendered HTTP responses. The distinction exists only so ? can do its job:

use suprnova::{Request, Response, json_response};

pub async fn show(req: Request) -> Response {
    // `?` short-circuits on Err. Each conversion below produces an
    // HttpResponse via a From impl - the chain collapses both arms.
    let id: i64 = req.param("id")?.parse().map_err(|_| {
        suprnova::FrameworkError::param_parse("id", "i64")
    })?;
    let user = User::find_or_fail(id).await?;  // 404 if missing
    Ok(json_response!({ "user": user }))
}

That single contract - every error path produces an HttpResponse through From - is the core of the model. Everything else in this chapter is what the various From impls actually do.

Why Suprnova diverges

Laravel throws exceptions and routes them through a global Handler class registered in app/Exceptions/Handler.php. The framework catches everything, asks the handler "what do I render?", and emits the response. PHP's unwinding-exception model makes this natural.

Rust has no unwinding exceptions in user code. Suprnova's equivalent is the From<FrameworkError> for HttpResponse impl plus the ErrorOccurred event. The conversion is the renderer; the event is where you hook observability (Sentry, PagerDuty, structured shippers). You don't register a handler class - the conversion is a function and listening for ErrorOccurred is the extension point. Same surface, different machinery.

FrameworkError - the canonical enum

Every error path inside the framework - extractors, route binding, the container, validation, the database layer, storage - produces a FrameworkError. It's an enum with fourteen variants, each tagged with its HTTP status:

pub enum FrameworkError {
    ServiceNotFound { type_name: &'static str },        // 500
    ParamError { param_name: String },                   // 400
    ValidationError { field: String, message: String },  // 422
    Database(String),                                    // 500
    Internal { message: String },                        // 500
    Domain { message: String, status_code: u16 },        // *
    Validation(ValidationErrors),                        // 422
    Unauthorized,                                        // 403
    ModelNotFound { model_name: String },                // 404
    ParamParse { param: String, expected_type: &'static str }, // 400
    UnsupportedMediaType,                                // 415
    PrecognitionSuccess,                                 // 204
    PrecognitionFailure(ValidationErrors),               // 422
    AlreadyReported,                                     // CLI-only
}

You rarely match on the variant. You construct one through a convenience constructor and let ? do the rest:

use suprnova::FrameworkError;

// All of these produce a FrameworkError with the right status:
FrameworkError::not_found("User");                    // → ModelNotFound, 404
FrameworkError::bad_request("Bad input");             // → Domain, 400
FrameworkError::param("user_id");                     // → ParamError, 400
FrameworkError::param_parse("user_id", "i64");        // → ParamParse, 400
FrameworkError::validation("email", "required");      // → ValidationError, 422
FrameworkError::domain("Conflict", 409);              // → Domain, 409
FrameworkError::internal("disk full");                // → Internal, 500
FrameworkError::database("timeout");                  // → Database, 500

There are no unauthorized() or forbidden() constructors on FrameworkError - Unauthorized is a fixed variant carrying the Laravel "This action is unauthorized." message at 403, and 401 cases go through AppError::unauthorized (next section). Note: the variant is named Unauthorized but the status is 403 because it models Laravel's authorization rejection, not HTTP authentication.

Automatic conversion

FrameworkError implements From<sea_orm::DbErr> and From<opendal::Error> so database and storage errors flow through ? without a wrap:

use suprnova::{DB, FrameworkError};
use sea_orm::ActiveModelTrait;

pub async fn create_user(new_user: ActiveModel) -> Result<Model, FrameworkError> {
    // Both `?` calls here convert into FrameworkError automatically:
    // - DB::get returns Result<_, FrameworkError>
    // - insert returns Result<_, DbErr>, which has From<DbErr> for FrameworkError
    let user = new_user.insert(&*DB::get()?).await?;
    Ok(user)
}

If your code returns Result<_, FrameworkError>, every common error your dependencies produce already speaks the right language. The controller's ? does no work beyond converting one error type into another.

Wrapping context

When you need to re-raise an error with operation context, 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>". The variant is preserved where it matters - Validation, ValidationError, PrecognitionFailure, Unauthorized, ModelNotFound, and ParamParse keep their structure so the response renderer still emits the correct shape. Plain message-carrying variants (Internal, Database, Domain) flatten into a Domain with the prefixed message.

AppError - ad-hoc domain errors

For one-off errors where you don't want to define a dedicated type, use AppError. It implements HttpError and has a From into FrameworkError, so ? works directly:

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 > 1_000_000 {
        return Err(AppError::forbidden("amount exceeds daily limit").into());
    }

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

The constructors map cleanly onto Laravel's abort($status, $msg) shape:

AppError::* Status
bad_request(msg) 400
unauthorized(msg) 401
forbidden(msg) 403
not_found(msg) 404
conflict(msg) 409
unprocessable(msg) 422
new(msg) 500
.status(code) any

Note AppError::unauthorized is 401 (HTTP authentication missing), while FrameworkError::Unauthorized is 403 (authorization denied, matching Laravel's policy rejection). They mean different things; pick the one that matches the failure.

HttpError - custom typed errors

When the same domain error appears in many places, model it as a type. Implement HttpError and the conversion is yours:

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)
    }
}

HttpError has two methods, both with defaults:

pub trait HttpError: std::error::Error + Send + Sync + 'static {
    fn status_code(&self) -> u16 { 500 }
    fn error_message(&self) -> String { self.to_string() }
}

Bridging to ?

A naive impl<T: HttpError> From<T> for FrameworkError would conflict with the existing From<AppError> impl (because AppError itself implements HttpError). Suprnova resolves the orphan-rule problem with an explicit bridge constructor instead:

use suprnova::{FrameworkError, HttpError};

pub async fn debit(account: &mut Account, amount: i64) -> Result<(), FrameworkError> {
    account.withdraw(amount)
        .map_err(FrameworkError::from_http_error)?;
    Ok(())
}

The status code and message are taken from HttpError::status_code and HttpError::error_message and stored in a FrameworkError::Domain variant. The response renderer then follows the normal Domain path.

#[domain_error] for boilerplate-free types

If you want the typed-error pattern without writing the Display, Error, and HttpError impls by hand, use the #[domain_error] attribute macro:

use suprnova::domain_error;

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

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

#[domain_error] generates the full impl set including From<YourError> for FrameworkError, so ? works directly with no bridge call:

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| FrameworkError::param_parse("id", "i64"))?;
    let user = User::find(id).await?
        .ok_or_else(|| FrameworkError::from(UserNotFoundError))?;
    Ok(json_response!({ "user": user }))
}

The three tiers of custom error story - AppError for inline, #[domain_error] for typed-with-macro, hand-rolled HttpError for full control - give you the right tool at every level of formality.

ValidationErrors - the Laravel-shaped error bag

When a request fails validation, Suprnova emits the same 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 must be at least 8 characters."]
    },
    "request_id": "8f9e1a2b-c3d4-..."
}

You usually don't build this by hand - #[derive(Validate)] on a form request and the validator crate behind it produces a validator::ValidationErrors which Suprnova converts via ValidationErrors::from_validator. But the type is public when you need 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 errors under a named bag (Laravel's withErrors($errors, 'profile') shape) by prepending the bag name with a . separator:

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": [...] }

retain_fields keeps only the listed entries - used internally by Precognition's Precognition-Validate-Only header so the server runs full validation but reports errors only for the fields the client asked about.

The conversion contract

When a FrameworkError reaches an HTTP boundary it goes through From<FrameworkError> for HttpResponse. Three things happen, in order:

  1. Status routing. The variant's status_code() is read once.
  2. Logging + observability. 5xx fires tracing::error! and dispatches ErrorOccurred; 4xx fires tracing::warn!. Both carry the request id when one is in scope.
  3. Body rendering. A JSON body in the Laravel shape, sanitised for 5xx.

The body shape

All error bodies follow the same JSON skeleton:

{
    "message": "<human readable>",
    "errors": { "field": ["msg", ...] },
    "request_id": "<uuid>" | null,
    "debug_message": "<dev only>"
}
  • message is always present.
  • errors only appears for validation-style errors (Validation, ValidationError) - both render the same shape so consumers parse one path.
  • request_id always appears (null when outside a request scope - e.g. during early boot or in tests with no request context).
  • debug_message only appears for 5xx when APP_DEBUG=true. It is strictly additive - production clients must not key on it.

The 5xx sanitisation rule

This is the safety guarantee worth memorising. For any error with status ≥ 500, the JSON body's message is replaced with the literal string:

{ "message": "Internal Server Error", "request_id": "..." }

The raw error detail does not leak to the response body. It goes to:

  • the tracing::error! log entry, with the request id and status
  • the ErrorOccurred event, which any listener can pick up

When APP_DEBUG=true (false by default outside local/dev/test), the response also carries a debug_message field with the raw detail - but message stays generic in both modes, so frontends and clients can't accidentally couple to dev-only data.

This is the contract that lets you call FrameworkError::internal("db connection refused: password mismatch on user 'app_rw'") without leaking the password to the wire. The message you pass is for operators reading logs; the message the client sees is "Internal Server Error".

For 4xx errors, the caller-facing message is preserved - 404 User not found, 400 Missing required parameter: user_id. These are domain errors the client needs to act on, not internal failures.

Where the contract lives

The whole conversion is one function - impl From<FrameworkError> for HttpResponse in framework/src/http/response.rs. Read it once and you've read the entire error rendering surface of Suprnova. There is no other path.

The panic boundary

A panic in a middleware or handler would otherwise propagate up the per-connection task and tear down the hyper service mid-response, leaving the client with a TCP reset and no HTTP response. Suprnova catches it.

execute_chain_safely in framework/src/server.rs wraps the middleware chain in AssertUnwindSafe(...).catch_unwind().await. On a panic it:

  1. Extracts the panic payload (handles &'static str and String payloads; anything else surfaces as "panic with non-string payload").
  2. Logs tracing::error! with the request method, path, and id.
  3. Constructs FrameworkError::internal(format!("request handler panicked: {msg}")) and routes it through the same From<FrameworkError> for HttpResponse conversion every other 5xx uses.
  4. Echoes the request id back as X-Request-Id.

The panic payload stays in the log entry; the client gets the sanitised {"message": "Internal Server Error"} body. Observability listeners that fire on ErrorOccurred for returned 5xx errors also fire on panics - there is no separate panic-event surface to wire up.

The same panic-recovery pattern is used by:

  • WebSocket handlers (framework/src/server.rs)
  • Scheduled tasks (framework/src/schedule/mod.rs)
  • Workflows (framework/src/workflow/mod.rs)
  • The Supervisor trait (broadcasting)

A panic in one of these subsystems is logged and either translated to an error state or auto-restarted; it does not bring down the worker task.

Hooking observability with ErrorOccurred

ErrorOccurred is a built-in event the framework dispatches on every 5xx response (including the ones synthesised from panics):

pub struct ErrorOccurred {
    pub error_message: String,
    pub status_code: u16,
    pub request_id: Option<String>,
}

Listen for it 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:
EventFacade::listen::<ErrorOccurred, _>(Arc::new(SentryReporter)).await;

This is the Suprnova equivalent of Laravel's report() callback on the global exception handler. The event arrives with the original unsanitised error_message (the body the client sees is still sanitised), the status code, and the correlatable request id.

Abort helpers

Three free functions short-circuit a handler at a given status. They mirror Laravel's abort / abort_if / abort_unless:

use suprnova::{abort_with, abort_if, abort_unless, Auth, Request, Response, json_response};

pub async fn show(req: Request) -> Response {
    abort_unless(Auth::check(), 401, "must be logged in")?;
    abort_if(req.param("id")? == "0", 404, "User not found")?;
    abort_with(503, "scheduled maintenance")?;
    Ok(json_response!({ "ok": true }))
}

Each returns Result<(), FrameworkError>. Use them with ?. The underlying error is FrameworkError::Domain { message, status_code }, so it renders through the same body shape and sanitisation rules as every other error. Out-of-range status codes are coerced to 500 by the response renderer's status validation; you don't need to defend against bad input at the call site.

The CLI sentinel: AlreadyReported

One variant of FrameworkError has no HTTP meaning. AlreadyReported is constructed via FrameworkError::silent() and used by the console dispatcher when clap has already formatted and printed its own argument-parse error. The binary's main translates the sentinel to a non-zero exit code without eprintln, so users never see two error messages for the same failure.

If AlreadyReported ever reaches an HTTP response converter, it indicates a request handler accidentally returned silent(). The converter logs a loud tracing::error! identifying the leak and returns a generic 500 - the variant has no business in the request path, and the loud log makes the bug observable instead of silent.

You don't normally see this variant; it's documented here because the enum is HTTP-flavoured and the otherwise-unexplained variant would puzzle anyone reading the source.

Safety guarantees, in summary

The contract Suprnova gives you:

  • Total conversion. Every FrameworkError produces an HttpResponse. There is no error path that crashes the server or drops the connection silently.
  • Sanitised 5xx. The wire body for any 5xx is the generic {"message": "Internal Server Error", "request_id": "..."}. Detail flows to logs + ErrorOccurred.
  • Optional debug visibility. APP_DEBUG=true adds a debug_message field for 5xx, never message. Production clients cannot accidentally couple to dev-only data.
  • Correlatable request ids. Every error body carries the request id (or null when no request scope exists); the same id appears in the log line and the ErrorOccurred event.
  • Panic recovery. Panics in handlers and middleware are caught, logged, and routed through the same From impl as returned errors. No connection drop, no observability gap.
  • One shape for everything. Validation errors, parameter errors, panics, custom domain errors, and storage failures all collapse to the same JSON skeleton. Frontend code parses one structure.

Where each piece lives

Piece File
FrameworkError, AppError, HttpError, ValidationErrors framework/src/error.rs
From<FrameworkError> for HttpResponse (conversion + sanitisation) framework/src/http/response.rs
abort, abort_if, abort_unless framework/src/http/abort.rs
execute_chain_safely (panic boundary) framework/src/server.rs
ErrorOccurred event framework/src/events/builtins.rs
#[domain_error] macro suprnova-macros/src/domain_error.rs

Next

  • Error Handling - the practical handler patterns that use this model
  • Request Lifecycle - where in the request flow the error conversion runs
  • Validation - #[derive(Validate)], form requests, and how ValidationErrors gets populated
  • Responses - HttpResponse builders, headers, cookies, streaming
  • Events - listening to ErrorOccurred and other built-in events