Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7. Error handling

HttpError

The simplest way to fail is to return an HttpError with a status and a message:

use lesto::prelude::*;

#[lesto::get("/items/{id}", responses(404))]
async fn get_item(Path(id): Path<u64>) -> Result<Json<Item>, HttpError> {
    if id != 1 {
        return Err(HttpError::not_found(format!("item {id} not found")));
    }
    Ok(Json(Item { name: "Keyboard".into() }))
}
curl -i http://127.0.0.1:8000/items/7
HTTP/1.1 404 Not Found
content-type: application/problem+json

{"type":"about:blank","title":"Not Found","status":404,"detail":"item 7 not found","instance":"/items/7"}

Ready-made constructors: bad_request, unauthorized, forbidden, not_found, conflict, internal. For other statuses, HttpError::new(418, "...") or HttpError::new(StatusCode::GONE, "..."). A bare number outside 100..=599 never panics: it becomes a 500 and the value is logged at error, so a status copied from an upstream response is safe to pass through.

The format: RFC 9457

Every error, yours or generated by lesto, is a Problem Details document (RFC 9457) with content type application/problem+json:

MemberMeaning
typeURI identifying the error class. about:blank when the status is enough.
titleShort description, the same for every error of that type. With about:blank it is the HTTP reason phrase.
statusThe HTTP code.
detailExplanation of this occurrence.
instanceURI of the request. lesto fills it in for you.
errorsExtension: the list of failed checks in 422s.

A client can therefore treat every error the same way, and 422s additionally carry the list of fields.

Your own error types

When the status alone is not enough, give the error a type. Clients can then tell errors apart by URI rather than by message:

HttpError::new(402, "Your current balance is 30, but that costs 50.")
    .with_type("https://example.com/probs/out-of-credit")
    .with_title("You do not have enough credit.")
    .with_extension("balance", 30)
    .with_extension("cost", 50)
{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "status": 402,
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/purchases",
  "balance": 30,
  "cost": 50
}

with_header adds headers to the response, for example Retry-After on a 429 or WWW-Authenticate on a 401.

A handy pattern is one function per domain error type:

fn out_of_credit(balance: u32, cost: u32) -> HttpError {
    HttpError::new(402, format!("Your current balance is {balance}, but that costs {cost}."))
        .with_type("https://example.com/probs/out-of-credit")
        .with_title("You do not have enough credit.")
        .with_extension("balance", balance)
        .with_extension("cost", cost)
}

Converting your application’s errors

If your service layer has its own error enum, implement From and use ?:

enum RepoError {
    NotFound,
    Conflict(String),
    Db(sqlx::Error),
}

impl From<RepoError> for HttpError {
    fn from(e: RepoError) -> Self {
        match e {
            RepoError::NotFound => HttpError::not_found("resource not found"),
            RepoError::Conflict(msg) => HttpError::conflict(msg),
            RepoError::Db(err) => {
                tracing::error!(?err, "database error");
                HttpError::internal("internal error")     // do not leak details
            }
        }
    }
}

#[lesto::get("/items/{id}", responses(404))]
async fn get_item(State(repo): State<Repo>, Path(id): Path<u64>) -> Result<Json<Item>, HttpError> {
    Ok(Json(repo.get(id).await?))
}

Documenting errors

The Result<T, HttpError> type makes a default response with the Problem schema appear. To list specific statuses use responses(...):

#[lesto::post("/users", status = 201, responses(409, 422))]

Every listed status appears in the documentation with the Problem schema and the reason phrase as description. The 422s of the extractors and the 401s of the security extractors are added on their own.

Errors generated by lesto

SituationStatuscode in errors
Path/query/body cannot be converted422value_error
Required field missing422missing
Malformed JSON422json_invalid
garde rule violated422value_error
Content-Type not JSON415–
Missing credentials (Bearer, ApiKey, Basic)401–
Unknown route404–
Known route, wrong method405–
Handler panicked500– (the panic message stays in the log)

The last three come from the router itself, so even a request that never reaches a handler gets a problem+json body with instance. App::fallback(handler) replaces the 404 when you need something else there (a single-page app, a redirect).

Recap

  • Err(HttpError::not_found("...")), with ? and From for domain errors.
  • Everything is RFC 9457: type, title, status, detail, instance, plus errors in 422s.
  • with_type and with_extension for structured errors; with_header for extra headers.
  • responses(404, 409) to document the statuses the handler may return.

Next: State and dependencies.