Rust Web Server with Axum 0.8: JSON Errors with thiserror and From
October 28, 2026
Episode 5 of the Rust Backend series, where we build the backend of a blog step by step. In episode 4 our API learned to return errors with a status code and a message, but the message is plain text, so every client has to guess its format. Today every error gets the same JSON shape, {"error": "..."}. We use the thiserror crate for the messages, catch axum's own JsonRejection, and meet error E0277: ? couldn't convert the error to ApiError, which shows how ? really works: it calls From. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 4 (axum 0.8.9, tokio, serde).
1. The problem
Start episode 4's server with cargo run, then in a second terminal:
curl -i localhost:3000/posts/9HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 16
post 9 not foundThe content type is text/plain. A JavaScript client that calls res.json() would fail on it. We want every error to look the same:
{"error": "post 9 not found"}2. Add thiserror and serde_json
Stop the server and add two crates:
cargo add thiserror serde_json(The video used thiserror 2.0.21 and serde_json 1.0.154.)
3. derive Error and #[error] messages
Two new use lines at the top of src/main.rs:
use serde_json::json;
use thiserror::Error;thiserror writes the boring code for us. Each variant gets an #[error("...")] attribute with its message; {0} is the first value inside the variant, the id:
#[derive(Debug, Error)]
enum ApiError {
#[error("post {0} not found")]
NotFound(u32),
#[error("title must not be empty")]
EmptyTitle,
}Now ApiError has a Display, so to_string() gives us that message, and the match only picks the status code. The json! macro from serde_json builds a JSON value, and Json turns it into a response:
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::EmptyTitle => StatusCode::BAD_REQUEST,
};
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}Run it and ask for post 9 again:
HTTP/1.1 404 Not Found
content-type: application/json
content-length: 28
{"error":"post 9 not found"}4. axum's 422 is still plain text
Now send a post without its body field:
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hello"}'HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 98
Failed to deserialize the JSON body into the target type: missing field `body` at line 1 column 17This 422 comes from axum's Json extractor, before our handler even runs.
5. Catch JsonRejection, and error E0277
We can catch it: instead of Json<NewPost>, the handler takes a Result, either the Json or a JsonRejection:
use axum::extract::rejection::JsonRejection;async fn create_post(
State(db): State<Db>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
let Json(new) = payload?;
check(&new)?;
// ... rest as beforecargo run fails:
error[E0277]: `?` couldn't convert the error to `ApiError`
--> src/main.rs:73:28
|
73 | let Json(new) = payload?;
| -------^ the trait `From<JsonRejection>` is not implemented for `ApiError`
| |
| this can't be annotated with `?` because it has type `Result<_, JsonRejection>`
|
note: `ApiError` needs to implement `From<JsonRejection>`
--> src/main.rs:32:1
|
32 | enum ApiError {
| ^^^^^^^^^^^^^
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` traitThat is how ? works: on Err(e) it does return Err(ApiError::from(e)). So we need impl From<JsonRejection> for ApiError.
6. #[from] writes the From for us
A new variant holds the rejection, marked with #[from]. #[error(transparent)] uses the rejection's own message:
#[derive(Debug, Error)]
enum ApiError {
#[error("post {0} not found")]
NotFound(u32),
#[error("title must not be empty")]
EmptyTitle,
#[error(transparent)]
BadJson(#[from] JsonRejection),
}The rejection knows its own status code, so the match asks it; for its message we use body_text():
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::EmptyTitle => StatusCode::BAD_REQUEST,
ApiError::BadJson(rejection) => rejection.status(),
};
let message = match &self {
ApiError::BadJson(rejection) => rejection.body_text(),
other => other.to_string(),
};
(status, Json(json!({ "error": message }))).into_response()
}
}update_post gets the same Result and ?.
7. The final code
src/main.rs:
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
use axum::extract::rejection::JsonRejection;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use thiserror::Error;
#[derive(Serialize, Clone)]
struct Post {
id: u32,
title: String,
body: String,
}
#[derive(Deserialize)]
struct NewPost {
title: String,
body: String,
}
#[derive(Default)]
struct Store {
posts: BTreeMap<u32, Post>,
next_id: u32,
}
type Db = Arc<Mutex<Store>>;
#[derive(Debug, Error)]
enum ApiError {
#[error("post {0} not found")]
NotFound(u32),
#[error("title must not be empty")]
EmptyTitle,
#[error(transparent)]
BadJson(#[from] JsonRejection),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::EmptyTitle => StatusCode::BAD_REQUEST,
ApiError::BadJson(rejection) => rejection.status(),
};
let message = match &self {
ApiError::BadJson(rejection) => rejection.body_text(),
other => other.to_string(),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
fn check(new: &NewPost) -> Result<(), ApiError> {
if new.title.trim().is_empty() {
return Err(ApiError::EmptyTitle);
}
Ok(())
}
#[tokio::main]
async fn main() {
let db = Db::default();
let app = Router::new()
.route("/posts", get(list_posts).post(create_post))
.route("/posts/{id}", get(get_post).put(update_post).delete(delete_post))
.with_state(db);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn create_post(
State(db): State<Db>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
let Json(new) = payload?;
check(&new)?;
let mut store = db.lock().unwrap();
store.next_id += 1;
let post = Post { id: store.next_id, title: new.title, body: new.body };
store.posts.insert(post.id, post.clone());
Ok((StatusCode::CREATED, Json(post)))
}
async fn get_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Post>, ApiError> {
let store = db.lock().unwrap();
let post = store.posts.get(&id).ok_or(ApiError::NotFound(id))?;
Ok(Json(post.clone()))
}
async fn list_posts(State(db): State<Db>) -> Json<Vec<Post>> {
let store = db.lock().unwrap();
Json(store.posts.values().cloned().collect())
}
async fn update_post(
State(db): State<Db>,
Path(id): Path<u32>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<Json<Post>, ApiError> {
let Json(new) = payload?;
check(&new)?;
let mut store = db.lock().unwrap();
let post = store.posts.get_mut(&id).ok_or(ApiError::NotFound(id))?;
post.title = new.title;
post.body = new.body;
Ok(Json(post.clone()))
}
async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<StatusCode, ApiError> {
let mut store = db.lock().unwrap();
store.posts.remove(&id).ok_or(ApiError::NotFound(id))?;
Ok(StatusCode::NO_CONTENT)
}8. Test it with curl
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hello"}'
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"","body":"No title"}'
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hi"'HTTP/1.1 422 Unprocessable Entity
content-type: application/json
content-length: 110
{"error":"Failed to deserialize the JSON body into the target type: missing field `body` at line 1 column 17"}
HTTP/1.1 400 Bad Request
content-type: application/json
content-length: 35
{"error":"title must not be empty"}
HTTP/1.1 400 Bad Request
content-type: application/json
content-length: 101
{"error":"Failed to parse the request body as JSON: EOF while parsing an object at line 1 column 13"}The same shape every time. Without the content-type header you get 415 Unsupported Media Type, also in JSON. (date: lines are left out above.)
Recap
thiserror:#[derive(Error)]and#[error("...")]giveApiErroraDisplayjson!({ "error": ... })gives every error the same JSON bodyResult<Json<T>, JsonRejection>catches axum's extractor errors?converts errors withFrom;E0277: ? couldn't convert the errormeans aFromis missing#[from]writes thatFromfor us400,404and422, all in JSON
Next episode: our main.rs is getting long, so we split it into modules: models, handlers and errors, each in its own file. Watch the series on YouTube.