rust
57 lines · 9 steps
Typed error handling in an Axum handler
An Axum route extracts typed params, validates input, queries the database, and maps every failure to an HTTP response through one error enum.
Explained by
highlit
1use axum::{
2 extract::{Path, State},
3 http::StatusCode,
4 response::{IntoResponse, Response},
5 Json,
6};
7use serde::Deserialize;
8use uuid::Uuid;
9
10#[derive(Deserialize)]
11pub struct PostParams {
12 workspace_id: Uuid,
13 post_slug: String,
14}
15
16pub async fn get_post(
17 State(state): State<AppState>,
18 Path(PostParams { workspace_id, post_slug }): Path<PostParams>,
19) -> Result<Json<Post>, ApiError> {
20 if post_slug.len() > 128 || !post_slug.chars().all(|c| c.is_alphanumeric() || c == '-') {
21 return Err(ApiError::InvalidSlug);
22 }
23
24 let post = sqlx::query_as::<_, Post>(
25 "SELECT * FROM posts WHERE workspace_id = $1 AND slug = $2",
26 )
27 .bind(workspace_id)
28 .bind(&post_slug)
29 .fetch_optional(&state.db)
30 .await?
31 .ok_or(ApiError::NotFound)?;
32
33 Ok(Json(post))
34}
35
36pub enum ApiError {
37 InvalidSlug,
38 NotFound,
39 Database(sqlx::Error),
40}
41
42impl From<sqlx::Error> for ApiError {
43 fn from(err: sqlx::Error) -> Self {
44 ApiError::Database(err)
45 }
46}
47
48impl IntoResponse for ApiError {
49 fn into_response(self) -> Response {
50 let (status, message) = match self {
51 ApiError::InvalidSlug => (StatusCode::BAD_REQUEST, "invalid post slug"),
52 ApiError::NotFound => (StatusCode::NOT_FOUND, "post not found"),
53 ApiError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error"),
54 };
55 (status, Json(serde_json::json!({ "error": message }))).into_response()
56 }
57}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A single error enum implementing IntoResponse centralizes how every failure becomes an HTTP status and body.
- 2Implementing From lets the `?` operator convert library errors into your domain error automatically.
- 3Destructuring extractors in the function signature pulls typed request data straight into named locals.
Related explainers
python
from flask import Blueprint, jsonify from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError
Building a health check endpoint in Flask
health-check
blueprint
error-handling
Intermediate
6 steps
rust
#[derive(Debug, Clone, PartialEq)] pub enum Token { Number(f64), Plus,
How a tokenizer turns text into tokens
lexing
enums
iterators
Intermediate
8 steps
java
public final class EmailNormalizer { private static final Pattern EMAIL_PATTERN = Pattern.compile( "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
Normalizing email addresses in Java
validation
regex
normalization
Intermediate
8 steps
rust
use axum::{ extract::{FromRequestParts, Host}, http::{request::Parts, StatusCode}, };
Multi-tenant DB routing in Axum extractors
multi-tenancy
extractors
connection-pooling
Advanced
8 steps
python
from datetime import date, timedelta from typing import Annotated from fastapi import APIRouter, Depends, Query
Validating date ranges with FastAPI dependencies
dependency-injection
validation
pydantic
Intermediate
6 steps
php
<?php namespace App\Http\Requests\DataObjects;
Typed request DTOs in Laravel
data-transfer-object
validation
immutability
Intermediate
6 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/typed-error-handling-in-an-axum-handler-explained-rust-28cf/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.