M Logo
📁 Rust Backend with Axum · Part 4 of 42

Rust Web Server with Axum 0.8: Error Handling with Result, ? and IntoResponse

October 26, 2026

Episode 4 of the Rust Backend series, where we build the backend of a blog step by step. In episode 3 our API learned all of CRUD, but when something goes wrong it does not say what: a missing post is a bare 404 with an empty body, and a post with an empty title is accepted. Today we fix that with Result, the ? operator and our own error type. On the way we meet one of axum's most confusing compile errors, E0277: the trait Handler is not implemented, and see what it really means. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 3 (axum 0.8, tokio, serde with the derive feature). No new crates today.

1. The problem

Start episode 3's server with cargo run, then in a second terminal ask for a post that does not exist, and create a post with an empty title:

bash
curl -i localhost:3000/posts/9
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"","body":"No title"}'
text
HTTP/1.1 404 Not Found
content-length: 0

HTTP/1.1 201 Created
content-type: application/json
content-length: 37

{"id":1,"title":"","body":"No title"}

The 404 has no body, so the client does not learn why. And the empty title is accepted.

2. An error enum

An enum lists everything that can go wrong. For now, one case: NotFound, which carries the id of the missing post. Add it below type Db:

rust
enum ApiError {
    NotFound(u32),
}

get_post now returns a Result with our ApiError. ok_or turns a missing post into NotFound, and the ? operator does the rest: on Err it returns the error from the function right away, on Ok it gives us the value.

rust
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()))
}

3. Error E0277: Handler is not implemented

cargo run fails:

text
error[E0277]: the trait bound `fn(State<_>, Path<u32>) -> _ {get_post}: Handler<_, _>` is not satisfied
   --> src/main.rs:36:35
    |
 36 |         .route("/posts/{id}", get(get_post).put(update_post).delete(delete_post))
    |                               --- ^^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(State<Arc<Mutex<Store>>>, Path<u32>) -> _ {get_post}`
    |                               |
    |                               required by a bound introduced by this call
    |
    = note: Consider using `#[axum::debug_handler]` to improve the error message
...
error: could not compile `blog_api` (bin "blog_api") due to 1 previous error

It sounds scary, but it means one thing: axum must turn whatever a handler returns into an HTTP response. Json and StatusCode can do that, because they implement the IntoResponse trait. Our ApiError does not, so axum does not know which status code it should be.

4. impl IntoResponse for ApiError

So we teach it. into_response matches the error and picks a status code and a message. A tuple of (StatusCode, String) already knows how to become a response, so we call into_response on it. Add this import as line 2:

rust
use axum::response::{IntoResponse, Response};

and this block below the enum:

rust
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("post {id} not found")),
        };
        (status, message).into_response()
    }
}

It compiles now. Post nine again:

bash
curl -i localhost:3000/posts/9
text
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 16

post 9 not found

Still 404, but now with a message.

5. Empty titles and a check function

A second case, EmptyTitle, becomes 400 Bad Request with its own message. A small check function returns Result<(), ApiError>: nothing to return when all is fine, but it can fail. With ?, an empty title stops a handler on its first line.

create_post and update_post call check(&new)?, return Result, and wrap the good answer in Ok. In delete_post, ok_or plus ? replaces the whole match. The final src/main.rs:

rust
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

#[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>>;

enum ApiError {
    NotFound(u32),
    EmptyTitle,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("post {id} not found")),
            ApiError::EmptyTitle => (StatusCode::BAD_REQUEST, "title must not be empty".to_string()),
        };
        (status, 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>,
    Json(new): Json<NewPost>,
) -> Result<(StatusCode, Json<Post>), ApiError> {
    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>,
    Json(new): Json<NewPost>,
) -> Result<Json<Post>, ApiError> {
    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)
}

6. Test it with curl

Run the server with cargo run, then:

bash
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":"Hello"}'
curl -i localhost:3000/posts/7 -X PUT -H 'content-type: application/json' -d '{"title":"Hi","body":"Edited"}'
text
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 23

title must not be empty

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 17

HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 16

post 7 not found

The 422 comes from axum itself: the Json extractor has its own error type (JsonRejection), and it turns into a response the same way ours does. (date: lines are left out above.)

Recap

  • enum ApiError lists what can go wrong
  • impl IntoResponse for ApiError turns each error into a status code and a message
  • E0277: Handler is not implemented usually means the handler returns something that is not IntoResponse
  • Result<T, ApiError> in every handler, and ? to return errors early
  • 400, 404 and 422, each with a message

Next episode: even better errors: JSON error bodies, the thiserror crate, and From for automatic conversion. Watch the series on YouTube.

© 2026 All Rights Reserved By Mahmudul Alam