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:
| Member | Meaning |
|---|---|
type | URI identifying the error class. about:blank when the status is enough. |
title | Short description, the same for every error of that type. With about:blank it is the HTTP reason phrase. |
status | The HTTP code. |
detail | Explanation of this occurrence. |
instance | URI of the request. lesto fills it in for you. |
errors | Extension: 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
| Situation | Status | code in errors |
|---|---|---|
| Path/query/body cannot be converted | 422 | value_error |
| Required field missing | 422 | missing |
| Malformed JSON | 422 | json_invalid |
| garde rule violated | 422 | value_error |
Content-Type not JSON | 415 | – |
Missing credentials (Bearer, ApiKey, Basic) | 401 | – |
| Unknown route | 404 | – |
| Known route, wrong method | 405 | – |
| Handler panicked | 500 | – (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?andFromfor domain errors.- Everything is RFC 9457:
type,title,status,detail,instance, pluserrorsin 422s. with_typeandwith_extensionfor structured errors;with_headerfor extra headers.responses(404, 409)to document the statuses the handler may return.
Next: State and dependencies.