4 releases (breaking)

0.4.0 May 8, 2024
0.3.0 May 6, 2024
0.2.0 Jan 28, 2024
0.1.0 Oct 19, 2023

#1153 in HTTP server


Used in 2 crates

AGPL-3.0

20KB
175 lines

axum-ctx

Axum error handling inspired by anyhow.

➡️ Documentation with examples ⬅️


lib.rs:

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 Result

2. Determine the maximum salary. But if there were no salaries in the database, there is no maximum

➡️ You need to handle an Option

3. 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)
}

Dependencies

~1.8–2.7MB
~50K SLoC