Skip to content
loco
v1.0
★ 6.9k Get started

Error model

loco-rs uses a single crate-wide error type. This page documents its shape, how it becomes an HTTP response, and the helpers for constructing it.

Result and Error

  • pub type Result<T, E = Error> = std::result::Result<T, E> (src/lib.rs:52).
  • pub use self::errors::Error (src/lib.rs:5) — re-exported at the crate root and in loco_rs::prelude.
  • Error is declared #[non_exhaustive] (src/errors.rs:31). Any match Error { .. } outside the crate must include a wildcard _ => arm — the compiler enforces this, and the crate itself relies on it (see the status map below).

Variant → HTTP status map

impl IntoResponse for Error (src/controller/mod.rs:180-253) matches on the error and produces (StatusCode, ErrorDetail), then serializes ErrorDetail as the JSON response body. Only seven variants are matched explicitly; every other variant falls through to the trailing _ => arm.

VariantProduced whenHTTP statusResponse ErrorDetail
NotFoundReturned by the not_found() helper, or directly.404{"error":"not_found","description":"Resource was not found"}
Unauthorized(String)Returned by the unauthorized(msg) helper, or directly. Also logs tracing::warn!(err) with the original message (the message itself is not sent to the client).401{"error":"unauthorized","description":"You do not have permission to access this resource"}
CustomError(StatusCode, ErrorDetail)Constructed directly when the caller wants an arbitrary status and body.passthrough — whatever StatusCode was suppliedpassthrough — whatever ErrorDetail was supplied
WithBacktrace { inner, backtrace }Wraps another error variant; produced by calling .bt() on an Error (backtrace is only captured when RUST_BACKTRACE is set — see Constructors below). Also prints the inner error (red, underlined) and the filtered backtrace to stdout via backtrace::print_backtrace.400{"error":"Bad Request"} (only the error reason is set, no description)
BadRequest(String)Returned by the bad_request(msg) helper, or directly.400{"error":"Bad Request","description":"<msg>"}
JsonRejection(JsonRejection)Axum’s Json extractor rejects a malformed/missing request body (surfaced via the Json<T> wrapper’s #[from_request(rejection(Error))]). Logs tracing::debug!(err = err.body_text(), ...).err.status() — axum’s own rejection status (commonly 400/415/422){"error":"Bad Request"}
Validation(ModelValidationErrors)A validator-crate validation failure converted #[from] ModelValidationErrors.400{"errors": <serde_json::Value of the field errors>} — note error/description are None here; only errors is populated
everything else (all other variants, current and future — this is what the #[non_exhaustive] catch-all covers)Any of the ~28 remaining variants (DB, Model, IO, Redis, Sqlx, Tera, YAML, Message, Any, InternalServerError, etc. — see the full list below)500{"error":"internal_server_error","description":"Internal Server Error"}

Every response, regardless of variant, is first logged at tracing::error! with error.msg / error.details fields before the match runs (src/controller/mod.rs:184-202).

ErrorDetail — the response body shape

#[derive(Debug, Serialize)]
pub struct ErrorDetail {
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<serde_json::Value>,
}

(src/controller/mod.rs:133-142)

Constructors:

FnSignatureBehavior
ErrorDetail::newnew<T1, T2>(error: T1, description: T2) -> Self (:147)Sets error; sets description to None if the passed description is an empty string, Some(..) otherwise. errors is always None.
ErrorDetail::with_reasonwith_reason<T>(error: T) -> Self (:161)Sets only error; description/errors are None.

The body is always wrapped in the crate’s own Json<T> type (src/controller/mod.rs:170-178, a thin axum::Json newtype), not raw axum::Json.

Constructor helpers on Error

src/errors.rs:153-177:

FnSignatureNotes
Error::wrapwrap(err: impl std::error::Error + Send + Sync + 'static) -> Self (:154)Boxes any error into Error::Any(Box::new(err)). Does not call .bt() (backtrace capture is commented out).
Error::msgmsg(err: impl std::error::Error + Send + Sync + 'static) -> Self (:158)Stringifies the error’s Display into Error::Message(err.to_string()). Also does not capture a backtrace.
Error::stringstring(s: &str) -> Self (:162)Builds Error::Message(s.to_string()) directly from a string slice. #[must_use].
Error::btbt(self) -> Self (:166)Captures std::backtrace::Backtrace::capture(). If the backtrace status is Disabled or Unsupported (i.e. RUST_BACKTRACE is unset), returns self unchanged — no allocation, no wrapping. Otherwise wraps self in Error::WithBacktrace. #[must_use].

Both wrap/msg are cheap-conversion helpers for turning a foreign std::error::Error into the crate’s Error at a call site (e.g. inside a handler using .map_err(Error::wrap)); bt is the opt-in backtrace wrapper used internally (e.g. the hand-written From<serde_json::Error> impl at src/errors.rs:24-28 does Self::JSON(val).bt()).

Controller helper functions

Free functions in src/controller/mod.rs for the common HTTP-facing variants, each returning Result<U> (i.e. always Err(..)):

FnSignaturefile:line
unauthorizedunauthorized<T: Into<String>, U>(msg: T) -> Result<U>:112
bad_requestbad_request<T: Into<String>, U>(msg: T) -> Result<U>:121
not_foundnot_found<T>() -> Result<T>:130

All three are re-exported from loco_rs::prelude.

Full variant list

The complete #[non_exhaustive] enum Error (src/errors.rs:32-151), with feature gates where present:

VariantFeature gate
WithBacktrace { inner: Box<Self>, backtrace: Box<Backtrace> }
Message(String)
QueueProviderMissing
TaskNotFound(String)
Scheduler(#[from] crate::scheduler::Error)
Axum(#[from] axum::http::Error)
Tera(#[from] tera::Error)
JSON(serde_json::Error)— (hand-rolled From, not #[from], so it can call .bt())
JsonRejection(#[from] JsonRejection)
YAMLFile(#[source] serde_yaml::Error, String)
YAML(#[from] serde_yaml::Error)
EmailSender(#[from] lettre::error::Error)
Smtp(#[from] smtp::Error)
Worker(String)
IO(#[from] std::io::Error)
DB(#[from] sea_orm::DbErr)with-db
ParseAddress(#[from] AddressError)
Unauthorized(String)
NotFound
BadRequest(String)
CustomError(StatusCode, ErrorDetail)
InternalServerError
InvalidHeaderValue(#[from] InvalidHeaderValue)
InvalidHeaderName(#[from] InvalidHeaderName)
InvalidMethod(#[from] InvalidMethod)
Model(#[from] crate::model::ModelError)with-db
Redis(#[from] redis::RedisError)worker_redis
Sqlx(#[from] sqlx::Error)worker
Storage(#[from] crate::storage::StorageError)
Cache(#[from] crate::cache::CacheError)
Generators(#[from] loco_gen::Error)debug_assertions
VersionCheck(#[from] depcheck::VersionCheckError)
Any(#[from] Box<dyn std::error::Error + Send + Sync>)
Validation(#[from] ModelValidationErrors)
AxumFormRejection(#[from] axum::extract::rejection::FormRejection)

Removed variants (breaking as of the 1.0 error-enum narrowing)

Commit 4a4a84ee (“narrow the Error enum — drop 4 low-value/leaky variants”) removed four variants that are confirmed absent from current source (src/errors.rs):

  • EnvVar(#[from] std::env::VarError)
  • Hash(String)
  • SemVer(#[from] semver::Error)
  • TaskJoinError(#[from] tokio::task::JoinError)

Any code that constructs or matches these four no longer compiles. Combined with #[non_exhaustive], every downstream match Error { .. } must carry a _ => arm — this was already required before the removal, but the removal is a reminder that new/removed variants must not break exhaustive matches, and user code must not attempt to rely on exhaustiveness.