Rust Web Practical: Building Elegant Unified Error Handling in Actix Web

Rust Web Practical: Building Elegant Unified Error Handling in Actix Web

Error handling is an essential core aspect when building any robust web service. A good service should not only run stably under normal conditions but also provide clear, unified, and safe responses when encountering issues such as database connection failures, invalid user input, or I/O exceptions.

Rust Web Practical: Building Elegant Unified Error Handling in Actix Web

However, integrating errors from different sources (such as databases, serialization, and business logic) into standardized HTTP responses often makes the code messy. This article will guide you through building an elegant, centralized error handling system using Rust and its high-performance web framework Actix Web, based on a complete project example. You will learn how to leverage Rust’s type system and Actix’s ResponseError trait to simplify and enhance error management.

Unified Error Handling in Web Services

Custom Error Types in Actix Web Service -> Custom Error to HTTP Response

  • Database
    • Database Error
  • Serialization
    • Serde Error
  • I/O Operations
    • I/O Error
  • Actix-Web Library
    • Actix Error
  • Invalid User Input
    • Invalid User Input Error

Error Handling in Actix-Web

  • Two common error handling methods in programming languages:
    • Exceptions
    • Return Values (Rust uses this)
  • Rust encourages developers to handle errors explicitly, so functions that may fail return a Result enum type, defined as follows:
enum Result<T, E> {
  Ok(T),
  Err(E),
}

Example

use std::num::ParseIntError;

fn main() {
let result = square("25");
println!("{:?}", result);
}

fn square(val: &str) -> Result<i32, ParseIntError> {
match val.parse::<i32>() {
    Ok(num) => Ok(num.pow(2)),
    Err(e) => Err(3),
  }
}

The ? Operator

  • Using the ? operator in a function attempts to retrieve a value from Result:
    • If unsuccessful, it receives the Error, halting function execution and propagating the error to the calling function.
use std::num::ParseIntError;

fn main() {
  let result = square("25");
  println!("{:?}", result);
}

fn square(val: &str) -> Result<i32, ParseIntError> {
  let num = val.parse::<i32>()?;
  Ok(num ^ 2)
}

Custom Error Types

  • Create a custom error type that can abstract multiple error types.
  • For example:
#[derive(Debug)]
pub enum MyError {
  ParseError,
 IOError,
}

Actix-Web Converts Errors to HTTP Responses

  • Actix-Web defines a generic error type (struct):<span>actix_web::error::Error</span>
    • It implements the <span>std::error::Error</span> trait
  • Any type that implements the standard library Error trait can be converted to Actix’s Error type using the ? operator
  • Actix’s Error type automatically converts to HTTP Response, returning it to the client.
  • ResponseError trait: Any error implementing this trait can be converted to HTTP Response messages.
  • Built-in implementations: Actix-Web has built-in implementations for common errors, such as:
  • Rust standard I/O errors
  • Serde errors
  • Web errors, such as: ProtocolError, Utf8Error, ParseError, etc.
  • Other error types: When built-in implementations are unavailable, custom implementations are needed to convert errors to HTTP Responses

Creating a Custom Error Handler

  1. Create a custom error type
  2. Implement the From trait to convert other error types to this type
  3. Implement the ResponseError trait for the custom error type
  4. Return the custom error type in the handler
  5. Actix will convert the error to HTTP responses

Project Directory

ws on  main [✘!?] via 🦀 1.67.1 via 🅒 base 
➜ tree -a -I target 
.
├── .env
├── .git
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── README.md
└── webservice
    ├── Cargo.toml
    └── src
        ├── bin
        │   ├── server1.rs
        │   └── teacher-service.rs
        ├── db_access.rs
        ├── errors.rs
        ├── handlers.rs
        ├── main.rs
        ├── models.rs
        ├── routers.rs
        └── state.rs

40 directories, 47 files

ws on  main [✘!?] via 🦀 1.67.1 via 🅒 base 

webservice/src/errors.rs

use actix_web::{error, http::StatusCode, HttpResponse, Result};
use serde::Serialize;
use sqlx::error::Error as SQLxError;
use std::fmt;

#[derive(Debug, Serialize)]
pub enum MyError {
    DBError(String),
    ActixError(String),
    NotFound(String),
}
#[derive(Debug, Serialize)]
pub struct MyErrorResponse {
    error_message: String,
}

impl MyError {
    fn error_response(&self) -> String {
        match self {
            MyError::DBError(msg) => {
                println!("Database error occurred: {:?}", msg);
                "Database error".into()
            }
            MyError::ActixError(msg) => {
                println!("Server error occurred: {:?}", msg);
                "Internal server error".into()
            }
            MyError::NotFound(msg) => {
                println!("Not found error occurred: {:?}", msg);
                msg.into()
            }
        }
    }
}

impl error::ResponseError for MyError {
    fn status_code(&self) -> StatusCode {
        match self {
            MyError::DBError(msg) | MyError::ActixError(msg) => StatusCode::INTERNAL_SERVER_ERROR,
            MyError::NotFound(msg) => StatusCode::NOT_FOUND,
        }
    }
    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code()).json(MyErrorResponse {
            error_message: self.error_response(),
        })
    }
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
        write!(f, "{}", self)
    }
}

impl From<actix_web::error::Error> for MyError {
    fn from(err: actix_web::error::Error) -> Self {
        MyError::ActixError(err.to_string())
    }
}

impl From<SQLxError> for MyError {
    fn from(err: SQLxError) -> Self {
        MyError::DBError(err.to_string())
    }
}

webservice/src/bin/teacher-service.rs

use actix_web::{web, App, HttpServer};
use dotenv::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::env;
use std::io;
use std::sync::Mutex;

#[path = "../db_access.rs"]
mod db_access;
#[path = "../errors.rs"]
mod errors;
#[path = "../handlers.rs"]
mod handlers;
#[path = "../models.rs"]
mod models;
#[path = "../routers.rs"]
mod routers;
#[path = "../state.rs"]
mod state;

use routers::*;
use state::AppState;

#[actix_rt::main]
async fn main() -> io::Result<()> {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set.");
    let db_pool = PgPoolOptions::new().connect(&database_url).await.unwrap();

    let shared_data = web::Data::new(AppState {
        health_check_response: "I'm Ok.".to_string(),
        visit_count: Mutex::new(0),
        // courses: Mutex::new(vec![]),
        db: db_pool,
    });
    let app = move || {
        App::new()
            .app_data(shared_data.clone())
            .configure(general_routes)
            .configure(course_routes) // Route registration
    };

    HttpServer::new(app).bind("127.0.0.1:3000")?.run().await
}

webservice/src/db_access.rs

use super::errors::MyError;
use super::models::*;
use chrono::NaiveDateTime;
use sqlx::postgres::PgPool;

pub async fn get_courses_for_teacher_db(
    pool: &PgPool,
    teacher_id: i32,
) -> Result<Vec<Course>, MyError> {
    let rows = sqlx::query!(
        r#"SELECT id, teacher_id, name, time FROM course WHERE teacher_id = $1"#,
        teacher_id
    )
    .fetch_all(pool)
    .await?;

    let courses: Vec<Course> = rows
        .iter()
        .map(|row| Course {
            id: Some(row.id),
            teacher_id: row.teacher_id,
            name: row.name.clone(),
            time: Some(NaiveDateTime::from(row.time.unwrap())),
        })
        .collect();

    match courses.len() {
        0 => Err(MyError::NotFound("Courses not found for teacher".into())),
        _ => Ok(courses),
    }
}

pub async fn get_courses_detail_db(pool: &PgPool, teacher_id: i32, course_id: i32) -> Course {
    let row = sqlx::query!(
        r#"SELECT id, teacher_id, name, time FROM course WHERE teacher_id = $1 and id = $2"#,
        teacher_id,
        course_id
    )
    .fetch_one(pool)
    .await
    .unwrap();

    Course {
        id: Some(row.id),
        teacher_id: row.teacher_id,
        name: row.name.clone(),
        time: Some(NaiveDateTime::from(row.time.unwrap())),
    }
}

pub async fn post_new_course_db(pool: &PgPool, new_course: Course) -> Course {
    let row = sqlx::query!(
        r#"INSERT INTO course (id, teacher_id, name) VALUES ($1, $2, $3)
        RETURNING id, teacher_id, name, time"#,
        new_course.id,
        new_course.teacher_id,
        new_course.name
    )
    .fetch_one(pool)
    .await
    .unwrap();

    Course {
        id: Some(row.id),
        teacher_id: row.teacher_id,
        name: row.name.clone(),
        time: Some(NaiveDateTime::from(row.time.unwrap())),
    }
}

webservice/src/handlers.rs

use super::db_access::*;
use super::errors::MyError;
use super::state::AppState;
use actix_web::{web, HttpResponse};

pub async fn health_check_handler(app_state: web::Data<AppState>) -> HttpResponse {
    let health_check_response = &app_state.health_check_response;
    let mut visit_count = app_state.visit_count.lock().unwrap();
    let response = format!("{} {} times", health_check_response, visit_count);
    *visit_count += 1;
    HttpResponse::Ok().json(&response)
}

use super::models::Course;

pub async fn new_course(
    new_course: web::Json<Course>,
    app_state: web::Data<AppState>,
) -> HttpResponse {
    let course = post_new_course_db(&app_state.db, new_course.into()).await;
    HttpResponse::Ok().json(course)
}

pub async fn get_courses_for_teacher(
    app_state: web::Data<AppState>,
    params: web::Path<usize>,
) -> Result<HttpResponse, MyError> {
    let teacher_id = i32::try_from(params.into_inner()).unwrap();
    get_courses_for_teacher_db(&app_state.db, teacher_id)
        .await
        .map(|courses| HttpResponse::Ok().json(courses))
}

pub async fn get_courses_detail(
    app_state: web::Data<AppState>,
    params: web::Path<(usize, usize)>,
) -> HttpResponse {
    let teacher_id = i32::try_from(params.0).unwrap();
    let course_id = i32::try_from(params.1).unwrap();
    let course = get_courses_detail_db(&app_state.db, teacher_id, course_id).await;
    HttpResponse::Ok().json(course)
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::http::StatusCode;
    use dotenv::dotenv;
    use sqlx::postgres::PgPoolOptions;
    use std::env;
    use std::sync::Mutex;

    #[actix_rt::test]// Asynchronous test
    async fn post_course_test() {
        dotenv().ok();
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
        let db_pool = PgPoolOptions::new().connect(&db_url).await.unwrap();
        let app_state: web::Data<AppState> = web::Data::new(AppState {
            health_check_response: "".to_string(),
            visit_count: Mutex::new(0),
            db: db_pool,
        });

        let course = web::Json(Course {
            teacher_id: 1,
            name: "Test course".into(),
            id: Some(3), // serial
            time: None,
        });

        let resp = new_course(course, app_state).await;
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[actix_rt::test]
    async fn get_all_courses_success() {
        dotenv().ok();
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
        let db_pool = PgPoolOptions::new().connect(&db_url).await.unwrap();
        let app_state: web::Data<AppState> = web::Data::new(AppState {
            health_check_response: "".to_string(),
            visit_count: Mutex::new(0),
            db: db_pool,
        });
        let teacher_id: web::Path<usize> = web::Path::from(1);
        let resp = get_courses_for_teacher(app_state, teacher_id)
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[actix_rt::test]
    async fn get_one_course_success() {
        dotenv().ok();
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
        let db_pool = PgPoolOptions::new().connect(&db_url).await.unwrap();
        let app_state: web::Data<AppState> = web::Data::new(AppState {
            health_check_response: "".to_string(),
            visit_count: Mutex::new(0),
            db: db_pool,
        });
        let params: web::Path<(usize, usize)> = web::Path::from((1, 1));
        let resp = get_courses_detail(app_state, params).await;
        assert_eq!(resp.status(), StatusCode::OK);
    }
}

Testing

ws on  main via 🦀 1.67.1 via 🅒 base 
➜ cargo test --bin teacher-service get_all_courses_success
   Compiling webservice v0.1.0 (/Users/qiaopengjun/rust/ws/webservice)
warning: unused variable: `msg`
  --> webservice/src/bin/../errors.rs:39:30
   |
39 |             MyError::DBError(msg) | MyError::ActixError(msg) => StatusCode::INTERNAL_SERVER_ERROR,
   |                              ^^^                        ^^^
   |
   = note: `#[warn(unused_variables)]` on by default
help: if this is intentional, prefix it with an underscore
   |
39 |             MyError::DBError(_msg) | MyError::ActixError(_msg) => StatusCode::INTERNAL_SERVER_ERROR,
   |                              ~~~~                        ~~~~/n
warning: unused variable: `msg`
  --> webservice/src/bin/../errors.rs:40:31
   |
40 |             MyError::NotFound(msg) => StatusCode::NOT_FOUND,
   |                               ^^^ help: if this is intentional, prefix it with an underscore: `_msg`

warning: `webservice` (bin "teacher-service"test) generated 2 warnings (run `cargo fix --bin "teacher-service" --tests` to apply 2 suggestions)
    Finished test [unoptimized + debuginfo] target(s) in 1.55s
     Running unittests src/bin/teacher-service.rs (target/debug/deps/teacher_service-32d6a48d6ee3c4b4)

running 1 test
test handlers::tests::get_all_courses_success ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s


ws on  main [✘!?] via 🦀 1.67.1 via 🅒 base 
➜ 

In the match branch, we only care about the type of error and do not use the specific error message msg, which can be renamed to _msg to eliminate the warning.

webservice/src/db_access.rs

use super::errors::MyError;
use super::models::*;
use chrono::NaiveDateTime;
use sqlx::postgres::PgPool;

pub async fn get_courses_for_teacher_db(
    pool: &PgPool,
    teacher_id: i32,
) -> Result<Vec<Course>, MyError> {
    let rows = sqlx::query!(
        r#"SELECT id, teacher_id, name, time FROM course WHERE teacher_id = $1"#,
        teacher_id
    )
    .fetch_all(pool)
    .await?;

    let courses: Vec<Course> = rows
        .iter()
        .map(|row| Course {
            id: Some(row.id),
            teacher_id: row.teacher_id,
            name: row.name.clone(),
            time: Some(NaiveDateTime::from(row.time.unwrap())),
        })
        .collect();

    match courses.len() {
        0 => Err(MyError::NotFound("Courses not found for teacher".into())),
        _ => Ok(courses),
    }
}

pub async fn get_courses_detail_db(
    pool: &PgPool,
    teacher_id: i32,
    course_id: i32,
) -> Result<Course, MyError> {
    let row = sqlx::query!(
        r#"SELECT id, teacher_id, name, time FROM course WHERE teacher_id = $1 and id = $2"#,
        teacher_id,
        course_id
    )
    .fetch_one(pool)
    .await;

    if let Ok(row) = row {
        Ok(Course {
            id: Some(row.id),
            teacher_id: row.teacher_id,
            name: row.name.clone(),
            time: Some(NaiveDateTime::from(row.time.unwrap())),
        })
    } else {
        Err(MyError::NotFound("Course Id not found".into()))
    }
}

pub async fn post_new_course_db(pool: &PgPool, new_course: Course) -> Result<Course, MyError> {
    let row = sqlx::query!(
        r#"INSERT INTO course (id, teacher_id, name) VALUES ($1, $2, $3)
        RETURNING id, teacher_id, name, time"#,
        new_course.id,
        new_course.teacher_id,
        new_course.name
    )
    .fetch_one(pool)
    .await?
    .unwrap();

    Ok(Course {
        id: Some(row.id),
        teacher_id: row.teacher_id,
        name: row.name.clone(),
        time: Some(NaiveDateTime::from(row.time.unwrap())),
    })
}

webservice/src/handlers.rs

use super::db_access::*;
use super::errors::MyError;
use super::state::AppState;
use actix_web::{web, HttpResponse};

pub async fn health_check_handler(app_state: web::Data<AppState>) -> HttpResponse {
    let health_check_response = &app_state.health_check_response;
    let mut visit_count = app_state.visit_count.lock().unwrap();
    let response = format!("{} {} times", health_check_response, visit_count);
    *visit_count += 1;
    HttpResponse::Ok().json(&response)
}

use super::models::Course;

pub async fn new_course(
    new_course: web::Json<Course>,
    app_state: web::Data<AppState>,
) -> Result<HttpResponse, MyError> {
    post_new_course_db(&app_state.db, new_course.into())
        .await
        .map(|course| HttpResponse::Ok().json(course))
}

pub async fn get_courses_for_teacher(
    app_state: web::Data<AppState>,
    params: web::Path<usize>,
) -> Result<HttpResponse, MyError> {
    let teacher_id = i32::try_from(params.into_inner()).unwrap();
    get_courses_for_teacher_db(&app_state.db, teacher_id)
        .await
        .map(|courses| HttpResponse::Ok().json(courses))
}

pub async fn get_courses_detail(
    app_state: web::Data<AppState>,
    params: web::Path<(usize, usize)>,
) -> Result<HttpResponse, MyError> {
    let teacher_id = i32::try_from(params.0).unwrap();
    let course_id = i32::try_from(params.1).unwrap();
    get_courses_detail_db(&app_state.db, teacher_id, course_id)
        .await
        .map(|course| HttpResponse::Ok().json(course))
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::http::StatusCode;
    use dotenv::dotenv;
    use sqlx::postgres::PgPoolOptions;
    use std::env;
    use std::sync::Mutex;

    #[ignore]
    #[actix_rt::test]// Asynchronous test
    async fn post_course_test() {
        dotenv().ok();
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
        let db_pool = PgPoolOptions::new().connect(&db_url).await.unwrap();
        let app_state: web::Data<AppState> = web::Data::new(AppState {
            health_check_response: "".to_string(),
            visit_count: Mutex::new(0),
            db: db_pool,
        });

        let course = web::Json(Course {
            teacher_id: 1,
            name: "Test course".into(),
            id: Some(5), // serial
            time: None,
        });

        let resp = new_course(course, app_state).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[actix_rt::test]
    async fn get_all_courses_success() {
        dotenv().ok();
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
        let db_pool = PgPoolOptions::new().connect(&db_url).await.unwrap();
        let app_state: web::Data<AppState> = web::Data::new(AppState {
            health_check_response: "".to_string(),
            visit_count: Mutex::new(0),
            db: db_pool,
        });
        let teacher_id: web::Path<usize> = web::Path::from(1);
        let resp = get_courses_for_teacher(app_state, teacher_id)
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[actix_rt::test]
    async fn get_one_course_success() {
        dotenv().ok();
        let db_url = env::var("DATABASE_URL").expect("DATABASE_URL is not set");
        let db_pool = PgPoolOptions::new().connect(&db_url).await.unwrap();
        let app_state: web::Data<AppState> = web::Data::new(AppState {
            health_check_response: "".to_string(),
            visit_count: Mutex::new(0),
            db: db_pool,
        });
        let params: web::Path<(usize, usize)> = web::Path::from((1, 1));
        let resp = get_courses_detail(app_state, params).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }
}

Testing

ws on  main [✘!?] via 🦀 1.67.1 via 🅒 base took 2.1s 
➜ cargo test --bin teacher-service                           
   Compiling webservice v0.1.0 (/Users/qiaopengjun/rust/ws/webservice)
warning: unused variable: `msg`
  --> webservice/src/bin/../errors.rs:39:30
   |
39 |             MyError::DBError(msg) | MyError::ActixError(msg) => StatusCode::INTERNAL_SERVER_ERROR,
   |                              ^^^                        ^^^
   |
   = note: `#[warn(unused_variables)]` on by default
help: if this is intentional, prefix it with an underscore
   |
39 |             MyError::DBError(_msg) | MyError::ActixError(_msg) => StatusCode::INTERNAL_SERVER_ERROR,
   |                              ~~~~                        ~~~~/n
warning: unused variable: `msg`
  --> webservice/src/bin/../errors.rs:40:31
   |
40 |             MyError::NotFound(msg) => StatusCode::NOT_FOUND,
   |                               ^^^ help: if this is intentional, prefix it with an underscore: `_msg`

warning: `webservice` (bin "teacher-service"test) generated 2 warnings (run `cargo fix --bin "teacher-service" --tests` to apply 2 suggestions)
    Finished test [unoptimized + debuginfo] target(s) in 1.27s
     Running unittests src/bin/teacher-service.rs (target/debug/deps/teacher_service-32d6a48d6ee3c4b4)

running 3 tests
test handlers::tests::post_course_test ... ignored
test handlers::tests::get_one_course_success ... ok
test handlers::tests::get_all_courses_success ... ok

test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s


ws on  main [✘!?] via 🦀 1.67.1 via 🅒 base 
➜ 

Conclusion

Through the practical exercises in this article, we successfully built a robust and elegant unified error handling mechanism for Actix Web services.

Let’s review and summarize the core steps:

  1. Define a unified error type: We created the MyError enum to encapsulate all possible errors, such as DBError, ActixError, and NotFound.

  2. Implement automatic type conversion: By implementing the From trait for MyError, we allow external error types like sqlx::Error to be automatically converted to our custom error using the ? operator, greatly simplifying the code.

  3. Bridge to HTTP responses: The most critical step was implementing the actix_web::error::ResponseError trait. This allows the Actix Web framework to automatically recognize our MyError and convert it to the corresponding HTTP status code and JSON response body based on our defined logic.

  4. Apply to business logic: Finally, we applied the new error handling mechanism to the database access and request handling (handler) layers, making the error flow of the entire service clear and the handling logic centralized.

By following this pattern, your Rust Web application will become more professional and reliable. The code will not only be easier to read and maintain but will also provide clients with consistent and predictable error messages, which is an important foundation for building high-quality Web APIs.

Rust Web Practical: Building Elegant Unified Error Handling in Actix Web

Give a “like” to let me know you enjoyed it, and a “recommend” to let more “Seekers of the Moon” see it.

Leave a Comment