Expand description
Axum error handling inspired by anyhow
§Comparison to anyhow
Assume a function can_fail that returns Result<T, E> or Option<T>.
With anyhow, you can do the following:
use anyhow::{Context, Result};
let value = can_fail().context("Error message")?;For many types of programs, this is more than enough.
But for web backends, you don’t only want to report an error.
You want to return a response with a proper HTTP status code.
Then you want to log the error (using tracing).
This is what axum-ctx does:
// Use a wildcard for the best user experience
use axum_ctx::*;
let value = can_fail().ctx(StatusCode::BAD_REQUEST).log_msg("Error message")?;If an error occurs, the user gets the error message “400 Bad Request” corresponding to the status code that you specified. But you can replace this default message with a custom error message to be shown to the user:
let value = can_fail()
.ctx(StatusCode::UNAUTHORIZED)
// Shown to the user
.user_msg("You are not allowed to access this resource!")
// NOT shown to the user, only for the log
.log_msg("Someone tries to pentest you")?;A second call of user_msg replaces the the user error message.
But calling log_msg multiple times creates a backtrace:
fn returns_resp_result() -> RespResult<()> {
can_fail().ctx(StatusCode::NOT_FOUND).log_msg("Inner error message")
}
let value = returns_resp_result()
.log_msg("Outer error message")?;The code above leads to the following log message:
2024-05-08T22:17:53.769240Z INFO axum_ctx: 404 Not Found
0: Outer error message
1: Inner error message§Lazy evaluation
Similar to with_context provided by anyhow, axum-ctx also supports lazy evaluation of messages.
You just provide a closure to user_msg or log_msg:
let resource_name = "foo";
let value = can_fail()
.ctx(StatusCode::UNAUTHORIZED)
.user_msg(|| format!("You are not allowed to access the resource {resource_name}!"))
.log_msg(|| format!("Someone tries to access {resource_name}"))?;.user_msg(format!("…")) creates the string on the heap even if can_fail didn’t return Err (or None for options).
.user_msg(|| format!("…")) (a closure with two pipes ||) only creates the string if Err/None actually occurred.
§Logging
axum-ctx uses tracing for logging.
This means that you need to initialize a tracing subscriber in your program first before being able to see the log messages of axum-ctx.
axum-ctx automatically chooses a tracing level depending on the chosen status code.
Here is the default range mapping (status codes less than 100 or bigger than 999 are not allowed):
| Status Code | Level |
|---|---|
100..400 | Debug |
400..500 | Info |
500..600 | Error |
600..1000 | Trace |
You can change the default level for one or more status codes using change_tracing_level on program initialization
§Example
Assume that you want to get all salaries from a database and then return their maximum from an Axum API.
The steps required:
1. Get all salaries from the database. This might fail for example if the database isn’t reachable
➡️ You need to handle a
Result2. Determine the maximum salary. But if there were no salaries in the database, there is no maximum
➡️ You need to handle an
Option3. Return the maximum salary as JSON.
First, let’s define a function to get all salaries:
async fn salaries_from_db() -> Result<Vec<f64>, String> {
// Imagine getting this error while trying to connect to the database.
Err(String::from("Database unreachable"))
}Now, let’s see how to do proper handling of Result and Option in an Axum handler:
use axum::Json;
use http::StatusCode;
use tracing::{error, info};
async fn max_salary() -> Result<Json<f64>, (StatusCode, &'static str)> {
let salaries = match salaries_from_db().await {
Ok(salaries) => salaries,
Err(error) => {
error!("Failed to get all salaries from the DB\n{error}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong. Please try again later",
));
}
};
match salaries.iter().copied().reduce(f64::max) {
Some(max_salary) => Ok(Json(max_salary)),
None => {
info!("The maximum salary was requested although there are no salaries");
Err((StatusCode::NOT_FOUND, "There are no salaries yet!"))
}
}
}Now, compare the code above with the one below that uses axum-ctx:
use axum_ctx::*;
async fn max_salary() -> RespResult<Json<f64>> {
salaries_from_db()
.await
.ctx(StatusCode::INTERNAL_SERVER_ERROR)
.user_msg("Something went wrong. Please try again later")
.log_msg("Failed to get all salaries from the DB")?
.iter()
.copied()
.reduce(f64::max)
.ctx(StatusCode::NOT_FOUND)
.user_msg("There are no salaries yet!")
.log_msg("The maximum salary was requested although there are no salaries")
.map(Json)
}Isn’t that a wonderful chain? ⛓️ It is basically a “one-liner” if you ignore the pretty formatting.
The user gets the message “Something went wrong. Please try again later”. In your terminal, you get the following log message:
2024-05-08T22:17:53.769240Z ERROR axum_ctx: Something went wrong. Please try again later
0: Failed to get all salaries from the DB
1: Database unreachable“What about map_or_else and ok_or_else?”, you might ask.
You can use them if you prefer chaining like me, but the code will not be as concise as the one above with axum_ctx.
You can compare:
async fn max_salary() -> Result<Json<f64>, (StatusCode, &'static str)> {
salaries_from_db()
.await
.map_err(|error| {
error!("Failed to get all salaries from the DB\n{error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong. Please try again later",
)
})?
.iter()
.copied()
.reduce(f64::max)
.ok_or_else(|| {
info!("The maximum salary was requested although there are no salaries");
(StatusCode::NOT_FOUND, "There are no salaries yet!")
})
.map(Json)
}Structs§
- Message
- An error message.
- RespErr
- An error to be used as the error variant of a request handler.
- Status
Code - An HTTP status code (
status-codein RFC 9110 et al.).
Enums§
- Tracing
Level - The tracing level that maps to
tracing::Level.
Traits§
- Resp
ErrCtx - Conversion to a
ResultwithRespErras the error. - Resp
ErrExt - Addition of custom user and log error messages to a
Result<T, RespErr>.
Functions§
- change_
tracing_ level - Change the default tracing level for a status code.
Type Aliases§
- Resp
Result ResultwithRespErras the error variant.