Expand description
Typed, composable HTTP error sets for Axum and Aide.
axum-error-sets provides compile-time guarantees for HTTP error handling in Axum applications.
Instead of using monolithic error enums or loosely-typed responses, functions declare the exact set
of HTTP status codes they can return using type-level tuple sets (e.g., (NotFound, Unauthorized)).
§Key Concepts & Features
- Powered by
type-sets: Uses type-level set operations under the hood to manage, contain, and convert tuple sets of status codes at compile time. - No Per-Function Custom Error Enums: Eliminates the need to construct large, domain-wide error enums or
define bespoke
Errortypes for every function layer. - Exact Error Contracts: Functions declare precisely which HTTP status codes they can produce in their return signature.
- Subset-to-Superset Promotion: Error sets grow deterministically as they move up application layers
via
.into_superset(). Lower-level code remains precise without restricting higher-level callers. - Custom Response Formatting: Implement
IntoResponseWithon your central error payload type (e.g.,AppErrororStringError) to completely control how Axum converts error values intoIntoResponsefor any given status code. - Compile-Time Guarantees: Returning an undeclared status code produces a compiler error. Callers cannot silently “forget” or shrink handled error sets without explicit conversion.
- Aide & OpenAPI Integration: Implement
AideResponseForto automatically generate precise OpenAPI metadata for every status code in an error set.
For complete runnable code, visit the examples/ directory on GitHub.
§Example 1: Basic Status Mapping
Demonstrates converting Result types directly into typed HTTP error statuses using StatusResultExt.
mod common;
use axum_error_sets::{
StatusResultExt,
code::{InternalServerError, NotFound},
};
use common::{AppResultSet, StringError};
fn app_error() -> Result<String, StringError> {
Err(StringError::new("record missing"))
}
fn generic_error() -> Result<(), std::io::Error> {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"generic error",
))
}
// Maps standard Result<T, CommonError> to a 404 ApiError
fn get_user() -> AppResultSet<String, (NotFound,)> {
let user = app_error().into_not_found()?;
Ok(user)
}
// Maps multiple status codes within the same function
fn process_user() -> AppResultSet<String, (NotFound, InternalServerError)> {
let user = app_error().into_not_found()?;
if user.is_empty() {
return Err(StringError::new("payload corrupt")).into_internal()?;
}
// Since AppError implements From<T> where T: Error, the ? operator automatically
// converts the std::io::Error into an AppError.
generic_error().into_internal()?;
Ok(user)
}
fn main() {
println!("get_user: {:?}", get_user());
println!("process_user: {:?}", process_user());
}§Example 2: Error Set Composition
Demonstrates how lower-level functions with small error sets transparently expand into larger caller-level contracts using into_superset().
mod common;
use axum_error_sets::{
ResultSetExt, StatusResultExt,
code::{Conflict, InternalServerError, NotFound, Unauthorized},
};
use common::{AppResultSet, StringError};
use rootcause::report;
fn app_error(msg: &'static str) -> Result<String, StringError> {
Err(StringError::new(msg))
}
fn generic_io_error() -> Result<(), std::io::Error> {
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"database socket reset",
))
}
// -----------------------------------------------------------------------------
// Lower-Level Functions (Small Error Sets)
// -----------------------------------------------------------------------------
/// Database lookup function: only ever yields a 404 (NotFound).
fn fetch_user(id: &str) -> AppResultSet<String, (NotFound,)> {
if id != "valid_id" {
return app_error("user record not found")
.into_not_found()
.map_err(Into::into);
}
Ok(String::from("Alice"))
}
/// Authentication check: only ever yields a 401 (Unauthorized).
fn check_auth(token: &str) -> AppResultSet<(), (Unauthorized,)> {
if token.is_empty() {
app_error("missing auth token").into_unauthorized()?;
}
Ok(())
}
// -----------------------------------------------------------------------------
// Mid/High-Level Service (Growing Error Sets)
// -----------------------------------------------------------------------------
/// Service layer combining lower-level functions.
///
/// `into_superset()` expands both `(Unauthorized,)` and `(NotFound,)`
/// into the larger `(Unauthorized, NotFound, Conflict, InternalServerError)` set.
fn update_user_profile(
id: &str,
token: &str,
new_name: &str,
) -> AppResultSet<String, (Unauthorized, NotFound, Conflict, InternalServerError)> {
// 1. Promote (Unauthorized,) to full set
check_auth(token).into_superset()?;
// 2. Promote (NotFound,) to full set
let mut username = fetch_user(id).into_superset()?;
// 3. Directly introduce 409 (Conflict) at this layer
if new_name == "taken_username" {
return Err(Conflict(report!("username already taken")).into());
}
// 4. Automatically wrap external errors into 500 (InternalServerError)
generic_io_error().into_internal()?;
username.push_str(" -> ");
username.push_str(new_name);
Ok(username)
}
fn main() {
println!(
"Failed Auth: {:?}",
update_user_profile("valid_id", "", "NewName")
);
println!(
"Failed Fetch: {:?}",
update_user_profile("invalid_id", "token123", "NewName")
);
println!(
"Conflict Error: {:?}",
update_user_profile("valid_id", "token123", "taken_username")
);
}§Example 3: Axum Route Handlers & Aide OpenAPI Integration
Demonstrates integrating error sets directly into Axum handlers to automatically generate OpenAPI metadata.
mod common;
use aide::{
axum::{
ApiRouter,
routing::{get_with, post_with},
},
openapi::OpenApi,
};
use axum::{Json, extract::Path};
use axum_error_sets::{
ResultSetExt,
code::{BadRequest, Conflict, InternalServerError, NotFound},
};
use common::{AppResultSet, StringError};
use rootcause::report;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
// -----------------------------------------------------------------------------
// DTOs
// -----------------------------------------------------------------------------
#[derive(Serialize, Deserialize, JsonSchema)]
struct User {
id: String,
name: String,
}
#[derive(Deserialize, JsonSchema)]
struct CreateUserPayload {
name: String,
}
// -----------------------------------------------------------------------------
// Core Business Logic (Returns narrow error sets)
// -----------------------------------------------------------------------------
fn find_user_by_id(id: &str) -> AppResultSet<User, (NotFound,)> {
if id != "42" {
return Err(NotFound(StringError::new("user ID not found")).into());
}
Ok(User {
id: id.to_string(),
name: String::from("Alice"),
})
}
fn create_user_in_db(name: &str) -> AppResultSet<User, (Conflict, InternalServerError)> {
if name == "admin" {
return Err(Conflict(report!("username 'admin' is reserved")).into());
}
Ok(User {
id: String::from("43"),
name: name.to_string(),
})
}
// -----------------------------------------------------------------------------
// Axum Handlers with Aide Support
// -----------------------------------------------------------------------------
/// GET /users/{id}
///
/// OpenAPI documents both 404 (NotFound) and 500 (InternalServerError).
async fn get_user_handler(
Path(id): Path<String>,
) -> AppResultSet<Json<User>, (NotFound, InternalServerError)> {
let user = find_user_by_id(&id).into_superset()?;
Ok(Json(user))
}
/// POST /users
///
/// Combines payload validation (400), registration conflicts (409), and database failures (500).
async fn create_user_handler(
Json(payload): Json<CreateUserPayload>,
) -> AppResultSet<Json<User>, (BadRequest, Conflict, InternalServerError)> {
if payload.name.trim().is_empty() {
return Err(BadRequest(StringError::new("name cannot be empty")).into());
}
let new_user = create_user_in_db(&payload.name).into_superset()?;
Ok(Json(new_user))
}
// -----------------------------------------------------------------------------
// Router Setup & Main
// -----------------------------------------------------------------------------
#[tokio::main]
async fn main() {
let mut api = OpenApi::default();
// ApiRouter automatically extracts openapi metadata from declared handler error sets via AideResponseFor
let app = ApiRouter::<()>::new()
.api_route(
"/users/{id}",
get_with(get_user_handler, |op| {
op.description("Fetch a user by their unique identifier")
}),
)
.api_route(
"/users",
post_with(create_user_handler, |op| {
op.description("Register a new user")
}),
)
.finish_api(&mut api);
println!("OpenAPI Schema generated successfully!");
println!(
"Routes documented: {}",
serde_json::to_string_pretty(&api).unwrap()
);
// Run axum server with `app` here
let _ = app;
}§Example 4: Axum Route Handlers & Utoipa OpenAPI Integration
Demonstrates integrating error sets directly into Axum handlers to automatically generate OpenAPI metadata using Utoipa.
mod common;
use axum::{
Json, Router,
extract::Path,
routing::{get, post},
};
use axum_error_sets::{
ErrorSet, ResultSetExt,
code::{BadRequest, Conflict, InternalServerError, NotFound},
};
use common::{AppResultSet, StringError};
use rootcause::report;
use serde::{Deserialize, Serialize};
use utoipa::{OpenApi, ToSchema};
use crate::common::AppError;
// -----------------------------------------------------------------------------
// DTOs (using utoipa::ToSchema instead of schemars::JsonSchema)
// -----------------------------------------------------------------------------
#[derive(Serialize, Deserialize, ToSchema, OpenApi)]
struct User {
id: String,
name: String,
}
#[derive(Deserialize, ToSchema, OpenApi)]
struct CreateUserPayload {
name: String,
}
// -----------------------------------------------------------------------------
// Core Business Logic
// -----------------------------------------------------------------------------
fn find_user_by_id(id: &str) -> AppResultSet<User, (NotFound,)> {
if id != "42" {
return Err(NotFound(StringError::new("user ID not found")).into());
}
Ok(User {
id: id.to_string(),
name: String::from("Alice"),
})
}
fn create_user_in_db(name: &str) -> AppResultSet<User, (Conflict, InternalServerError)> {
if name == "admin" {
return Err(Conflict(report!("username 'admin' is reserved")).into());
}
Ok(User {
id: String::from("43"),
name: name.to_string(),
})
}
// -----------------------------------------------------------------------------
// Axum Handlers with Utoipa Support
// -----------------------------------------------------------------------------
/// GET /users/{id}
///
/// utoipa automatically expands `AppResultSet<Json<User>, ...>` via the `IntoResponses` trait.
#[utoipa::path(
get,
path = "/users/{id}",
params(
("id" = String, Path, description = "User ID")
),
responses(
(status = 200, description = "User retrieved successfully", body = User),
ErrorSet<AppError, (NotFound, InternalServerError)>
)
)]
async fn get_user_handler(
Path(id): Path<String>,
) -> AppResultSet<Json<User>, (NotFound, InternalServerError)> {
let user = find_user_by_id(&id).into_superset()?;
Ok(Json(user))
}
/// POST /users
#[utoipa::path(
post,
path = "/users",
request_body = CreateUserPayload,
responses(
(status = 200, description = "User created successfully", body = User),
ErrorSet<AppError, (NotFound, InternalServerError)>
)
)]
async fn create_user_handler(
Json(payload): Json<CreateUserPayload>,
) -> AppResultSet<Json<User>, (BadRequest, Conflict, InternalServerError)> {
if payload.name.trim().is_empty() {
return Err(BadRequest(StringError::new("name cannot be empty")).into());
}
let new_user = create_user_in_db(&payload.name).into_superset()?;
Ok(Json(new_user))
}
// -----------------------------------------------------------------------------
// OpenAPI Documentation Container
// -----------------------------------------------------------------------------
#[derive(OpenApi)]
#[openapi(
paths(get_user_handler, create_user_handler,),
components(schemas(User, CreateUserPayload))
)]
struct ApiDoc;
// -----------------------------------------------------------------------------
// Router Setup & Main
// -----------------------------------------------------------------------------
#[tokio::main]
async fn main() {
let api = ApiDoc::openapi();
let app = Router::<()>::new()
.route("/users/{id}", get(get_user_handler))
.route("/users", post(create_user_handler));
println!("OpenAPI Schema generated successfully!");
println!(
"Routes documented:\n{}",
serde_json::to_string_pretty(&api).unwrap()
);
let _ = app;
}Modules§
Structs§
- Error
Set - An error defined by a set of possible status codes.
Traits§
- Aide
Response For - Should be implemented for a type to be used as
Tinside [ApiError<T, _>], for usage withaide. - Into
Response With - Should be implemented for a type to be used as
Tinside [ApiError<T, _>]. - Result
SetExt - Extension trait for results, to convert errorsets into supersets.
- Status
Result Ext - Extension trait for
Resultto convert it into aResultwith an [ApiError]. - Status
Wrapper - Implemented for all types that can be used as
Einside [ApiError<_, E>]. - Utoipa
Response For