Skip to content
★ GitHubGet started

Handle errors

Goal: return the right HTTP status and JSON error body from a handler, without hand-writing impl IntoResponse yourself.

This assumes a working controller — see Add a controller. Every handler that returns loco_rs::Result<T> (i.e. Result<T, Error>) gets its error automatically converted to an HTTP response by impl IntoResponse for Error — you never call .into_response() on an error yourself. For the exhaustive variant list and constructors, see the Error model reference.

loco_rs::prelude re-exports three free functions for the HTTP-facing error variants you’ll reach for most:

use loco_rs::prelude::*;
async fn get_one(Path(id): Path<i64>, State(ctx): State<AppContext>) -> Result<Response> {
let Some(item) = find_item(&ctx, id).await? else {
return not_found();
};
format::json(item)
}
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
let Ok(user) = find_user(&ctx, &params.email).await else {
return unauthorized("invalid credentials");
};
// ...
format::json(user)
}
async fn create(Json(params): Json<CreateParams>) -> Result<Response> {
if params.title.is_empty() {
return bad_request("title is required");
}
// ...
format::empty()
}

Each returns Result<U> (always the Err arm), so return unauthorized(msg) type-checks against any handler’s Result<Response> return type. All three are also just regular ways to construct an Error and propagate it with ? from a helper function you call from the handler.

Fn HTTP status Response body Notes
not_found() 404 {"error":"not_found","description":"Resource was not found"} Takes no message.
unauthorized(msg) 401 {"error":"unauthorized","description":"You do not have permission to access this resource"} msg is logged (tracing::warn!) but not sent to the client.
bad_request(msg) 400 {"error":"Bad Request","description":"<msg>"} msg is sent to the client.

Error is #[non_exhaustive] with ~28 more variants (DB, Model, IO, Tera, Message, InternalServerError, …). Only seven variants get a specific status; the framework’s IntoResponse match ends in a wildcard arm — every other variant becomes 500 Internal Server Error with body {"error":"internal_server_error","description":"Internal Server Error"}.

In practice this means: if you ?-propagate a sea_orm::DbErr, an std::io::Error, or anything else that converts into Error via #[from], and you haven’t matched it explicitly, the client gets a generic 500 — which is usually what you want (don’t leak internals), and every response is logged at tracing::error! first regardless of variant, so you still see the real cause server-side.

The full variant → status table, including Validation (→ 400, from the validator crate) and JsonRejection (→ axum’s own rejection status), is in the Error model reference.

3. Return an arbitrary status: Error::CustomError

Section titled “3. Return an arbitrary status: Error::CustomError”

When none of the built-in helpers fit — a 409 Conflict, a 429 Too Many Requests, a body shape the framework doesn’t produce — build one directly:

use loco_rs::prelude::*;
use loco_rs::controller::ErrorDetail;
use axum::http::StatusCode;
async fn create(Json(params): Json<CreateParams>) -> Result<Response> {
if already_exists(&params).await? {
return Err(Error::CustomError(
StatusCode::CONFLICT,
ErrorDetail::new("conflict", "a resource with this name already exists"),
));
}
format::empty()
}

Error::CustomError(StatusCode, ErrorDetail) passes both the status and the body through unchanged — it’s the one variant the response mapping doesn’t rewrite. ErrorDetail::new(error, description) sets both fields (an empty description collapses to None); ErrorDetail::with_reason(error) sets only error. The response body is always {error, description, errors} with None fields omitted from the JSON.

4. Convert a foreign error without a dedicated variant

Section titled “4. Convert a foreign error without a dedicated variant”

Use Error::wrap/Error::msg at a ?/.map_err(..) call site to fold any std::error::Error into Error::Any/Error::Message (both fall through to the 500 catch-all above):

let parsed: MyType = serde_json::from_str(&raw).map_err(Error::wrap)?;

Reach for Error::string("...") when you have a plain &str/message and no source error to wrap.

If an endpoint needs to render errors differently for HTML vs. JSON clients, match on both the fallible call’s Result and the negotiated format in one place — see Respond with different formats.

Note the boundary: that negotiation is something your handler does. An Error that propagates out of a handler is rendered by the framework’s own IntoResponse, which always emits JSON ({"error": ..., "description": ...}) regardless of the request’s Accept header. If an HTML client must see an HTML error page, catch the Result in the handler as above rather than letting the error propagate.

Terminal window
curl -i localhost:5150/notes/999999 # -> 404 {"error":"not_found",...}
curl -i -X POST localhost:5150/auth/login -d '{"email":"x","password":"y"}' -H 'content-type: application/json'
# -> 401 {"error":"unauthorized",...}