rust
69 lines · 7 steps
Turning errors into RFC 7807 responses in Axum
An ApiError enum implements IntoResponse to render every failure as a structured problem+json payload with the right status code.
Explained by
highlit
1use axum::{
2 http::{header, StatusCode},
3 response::{IntoResponse, Response},
4 Json,
5};
6use serde::Serialize;
7use serde_json::json;
8
9#[derive(Debug, thiserror::Error)]
10pub enum ApiError {
11 #[error("resource not found")]
12 NotFound,
13 #[error("validation failed")]
14 Validation(Vec<String>),
15 #[error("insufficient permissions")]
16 Forbidden,
17 #[error("internal server error")]
18 Internal(#[from] anyhow::Error),
19}
20
21#[derive(Serialize)]
22struct ProblemDetails {
23 #[serde(rename = "type")]
24 type_uri: &'static str,
25 title: &'static str,
26 status: u16,
27 detail: String,
28 #[serde(skip_serializing_if = "Vec::is_empty")]
29 errors: Vec<String>,
30}
31
32impl IntoResponse for ApiError {
33 fn into_response(self) -> Response {
34 let (status, type_uri, title, errors) = match &self {
35 ApiError::NotFound => (StatusCode::NOT_FOUND, "about:blank", "Not Found", vec![]),
36 ApiError::Validation(e) => (
37 StatusCode::UNPROCESSABLE_ENTITY,
38 "https://errors.example.com/validation",
39 "Validation Failed",
40 e.clone(),
41 ),
42 ApiError::Forbidden => (StatusCode::FORBIDDEN, "about:blank", "Forbidden", vec![]),
43 ApiError::Internal(err) => {
44 tracing::error!(error = ?err, "unhandled internal error");
45 (
46 StatusCode::INTERNAL_SERVER_ERROR,
47 "about:blank",
48 "Internal Server Error",
49 vec![],
50 )
51 }
52 };
53
54 let body = Json(ProblemDetails {
55 type_uri,
56 title,
57 status: status.as_u16(),
58 detail: self.to_string(),
59 errors,
60 });
61
62 (
63 status,
64 [(header::CONTENT_TYPE, "application/problem+json")],
65 body,
66 )
67 .into_response()
68 }
69}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Implementing IntoResponse lets a domain error type become a first-class HTTP response, so handlers can just return it.
- 2Matching on the error variant centralizes status codes and messages in one place instead of scattering them across handlers.
- 3The RFC 7807 problem+json shape gives clients a consistent, machine-readable error contract.
Related explainers
rust
use axum::{extract::{Path, State}, http::StatusCode, Json}; use dashmap::DashMap; use serde::Serialize; use std::sync::Arc;
Request coalescing in an Axum handler
caching
concurrency
request-coalescing
Advanced
8 steps
ruby
class TemplateInterpolator PLACEHOLDER = /\{\{\s*([\w.]+)\s*\}\}/ def initialize(strict: false)
Interpolating templates with dotted keys in Ruby
regex
string-interpolation
hash-traversal
Intermediate
6 steps
go
package config import ( "fmt"
A thread-safe config singleton in Go
singleton
concurrency
environment-variables
Intermediate
7 steps
rust
use std::net::Ipv4Addr; use std::str::FromStr; #[derive(Debug, Clone, Copy)]
Parsing and matching IPv4 CIDR ranges in Rust
bitwise-operations
parsing
error-handling
Intermediate
8 steps
typescript
type CsvColumn<T> = { header: string; value: (row: T) => string | number | boolean | null | undefined; };
Building a type-safe CSV writer in TypeScript
generics
serialization
escaping
Intermediate
7 steps
rust
use std::sync::OnceLock; static CRC_TABLE: OnceLock<[u32; 256]> = OnceLock::new();
Table-driven CRC32 with lazy init in Rust
checksums
lazy-initialization
lookup-tables
Intermediate
8 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/turning-errors-into-rfc-7807-responses-in-axum-explained-rust-d7b2/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.