Error handling trong Rust với thiserror và anyhow

1. Tổng quan

Rust buộc bạn xử lý lỗi một cách tường minh qua Result<T, E>. Cách này an toàn, nhưng viết tay các kiểu lỗi thì dài dòng. Bạn phải tự implement Display, Error và From cho từng trường hợp.

Bài viết này giới thiệu hai crate phổ biến nhất để giải quyết việc đó. thiserror giúp định nghĩa kiểu lỗi có cấu trúc. anyhow giúp gom mọi lỗi về một kiểu duy nhất và gắn thêm context. Đọc xong, bạn sẽ biết dùng crate nào ở đâu, cách kết hợp chúng và cách test lỗi.

2. Vấn đề khi viết lỗi thủ công

Giả sử bạn đọc số lượng của một đơn hàng từ file. Có hai lỗi có thể xảy ra: lỗi I/O và lỗi parse. Nếu không dùng thư viện, bạn phải viết như sau:

use std::fmt;

#[derive(Debug)]
pub enum OrderError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl fmt::Display for OrderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            OrderError::Io(_) => write!(f, "không đọc được file đơn hàng"),
            OrderError::Parse(_) => write!(f, "dữ liệu đơn hàng sai định dạng"),
        }
    }
}

impl std::error::Error for OrderError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            OrderError::Io(e) => Some(e),
            OrderError::Parse(e) => Some(e),
        }
    }
}

impl From<std::io::Error> for OrderError {
    fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}

impl From<std::num::ParseIntError> for OrderError {
    fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}

Bạn cần gần 30 dòng boilerplate cho chỉ hai variant. Mỗi khi thêm một variant, bạn phải sửa ở bốn chỗ. Đây chính là việc thiserror làm thay bạn.

Một lối tắt khác là dùng Box<dyn std::error::Error>. Cách này gọn, nhưng nó mất thông tin kiểu và không cho bạn gắn thêm context. anyhow là phiên bản tốt hơn của lối tắt này.

3. Cài đặt

Thêm hai dependency vào Cargo.toml:

[dependencies]
thiserror = "2"
anyhow = "1"

Hai crate này độc lập với nhau. Một library thường chỉ cần thiserror. Một binary thường dùng anyhow, đôi khi dùng cả hai.

4. thiserror: kiểu lỗi có cấu trúc cho library

4.1. Định nghĩa enum lỗi

Với thiserror, đoạn code ở mục 2 rút gọn thành:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum OrderError {
    #[error("không tìm thấy đơn hàng #{0}")]
    NotFound(u64),

    #[error("số lượng không hợp lệ: {quantity} (phải > 0)")]
    InvalidQuantity { quantity: i32 },

    #[error("không đọc được file đơn hàng")]
    Io(#[from] std::io::Error),

    #[error("dữ liệu đơn hàng sai định dạng")]
    Parse(#[from] std::num::ParseIntError),
}

Mỗi phần có vai trò riêng:

  • #[derive(Error)] sinh implementation của std::error::Error.
  • #[error("...")] sinh Display. {0} tham chiếu field thứ nhất của tuple variant. {quantity} tham chiếu field có tên.
  • #[from] sinh impl From<std::io::Error> for OrderError. Nhờ đó toán tử ? tự chuyển đổi lỗi. Field có #[from] cũng tự động trở thành source().

Bây giờ hãy viết hàm dùng kiểu lỗi này:

use std::fs;

pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
    let quantity: i32 = raw.trim().parse()?;
    if quantity <= 0 {
        return Err(OrderError::InvalidQuantity { quantity });
    }
    Ok(quantity)
}

pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
    let content = fs::read_to_string(path)?;
    parse_quantity(&content)
}

Hàm fs::read_to_string trả về io::Error. Toán tử ? gọi From để chuyển nó thành OrderError::Io. Tương tự, ParseIntError được chuyển thành OrderError::Parse. Caller có thể match từng variant để xử lý riêng.

4.2. Duyệt chuỗi nguyên nhân với #[source]

Đôi khi bạn muốn giữ lỗi gốc nhưng không muốn tự động chuyển đổi bằng From. Khi đó, bạn dùng #[source] thay cho #[from]:

#[derive(Debug, Error)]
pub enum InventoryError {
    #[error("không cập nhật được tồn kho cho sản phẩm {sku}")]
    UpdateFailed {
        sku: String,
        #[source]
        cause: std::io::Error,
    },
}

Variant này có thêm field sku, nên From không thể tự sinh. Lúc này #[source] chỉ khai báo cause là nguyên nhân. Bạn có thể duyệt chuỗi nguyên nhân như sau:

use std::error::Error as _;

fn print_chain(err: &dyn std::error::Error) {
    eprintln!("Lỗi: {err}");
    let mut current = err.source();
    while let Some(cause) = current {
        eprintln!("  do: {cause}");
        current = cause.source();
    }
}

Khi gọi print_chain với một UpdateFailed bọc lỗi “permission denied”, bạn sẽ thấy:

Lỗi: không cập nhật được tồn kho cho sản phẩm SKU-42
  do: Permission denied (os error 13)

4.3. #[error(transparent)]

transparent chuyển thẳng Display và source() sang lỗi bên trong. Bạn dùng nó khi variant chỉ là lớp bọc và không thêm thông tin gì:

#[derive(Debug, Error)]
pub enum PaymentError {
    #[error("thẻ bị từ chối")]
    CardDeclined,

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

Variant Other đóng vai trò “lỗi còn lại”. Các lỗi cụ thể mà caller cần xử lý thì có variant riêng. Mọi lỗi khác được gom vào Other.

5. anyhow: một kiểu lỗi cho toàn bộ application

Ở tầng application, bạn hiếm khi match từng loại lỗi. Thông thường bạn chỉ cần log lỗi hoặc in ra cho người dùng, kèm đủ thông tin để biết chuyện gì đã xảy ra. anyhow::Error phục vụ đúng nhu cầu này.

5.1. anyhow::Result và context

use anyhow::{Context, Result};

fn read_port(path: &str) -> Result<u16> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("không đọc được file cấu hình {path}"))?;
    let port: u16 = raw
        .trim()
        .parse()
        .context("port phải là số nguyên trong khoảng 0–65535")?;
    Ok(port)
}

fn main() -> Result<()> {
    let port = read_port("app.conf")?;
    println!("Server chạy trên port {port}");
    Ok(())
}

Đoạn code hoạt động như sau:

  • anyhow::Result<T> là alias của Result<T, anyhow::Error>.
  • Mọi kiểu implement std::error::Error + Send + Sync + 'static đều chuyển được sang anyhow::Error qua ?.
  • .context(...) bọc lỗi gốc trong một thông điệp mới, và lỗi gốc vẫn được giữ làm nguyên nhân.
  • .with_context(|| ...) nhận closure. Closure chỉ chạy khi có lỗi, nên bạn tránh được việc gọi format! vô ích trên happy path.

Khi main trả về Err vì file không tồn tại, chương trình in ra:

Error: không đọc được file cấu hình app.conf

Caused by:
    No such file or directory (os error 2)

Bạn có ba cách định dạng một anyhow::Error:

  • {} chỉ in thông điệp ngoài cùng.
  • {:#} in cả chuỗi trên một dòng, phân tách bằng : .
  • {:?} in dạng nhiều dòng như trên.

Dùng {:#} khi log ra một dòng là tiện nhất.

Context cũng hoạt động với Option. Đây là cách gọn để biến None thành lỗi có ý nghĩa:

use anyhow::{Context, Result};
use std::collections::HashMap;

fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
    customers.get(&id).with_context(|| format!("không có khách hàng #{id}"))
}

5.2. bail!, ensure! và anyhow!

Ba macro này giúp tạo lỗi ad-hoc mà không cần định nghĩa kiểu:

use anyhow::{anyhow, bail, ensure, Result};

fn apply_discount(total: u64, percent: u8) -> Result<u64> {
    ensure!(percent <= 100, "phần trăm giảm giá không hợp lệ: {percent}");
    if total == 0 {
        bail!("không thể giảm giá cho đơn hàng 0 đồng");
    }
    total
        .checked_sub(total * percent as u64 / 100)
        .ok_or_else(|| anyhow!("tràn số khi tính giảm giá"))
}
  • ensure!(cond, ...) trả về lỗi nếu điều kiện sai.
  • bail!(...) tương đương return Err(anyhow!(...)).
  • anyhow!(...) tạo ra một giá trị anyhow::Error mà không return.

Ví dụ, apply_discount(500_000, 120) trả về lỗi phần trăm giảm giá không hợp lệ: 120.

5.3. Downcast khi vẫn cần phân loại lỗi

anyhow::Error vẫn giữ kiểu gốc. Bạn có thể downcast để xử lý một trường hợp đặc biệt:

fn handle(err: &anyhow::Error) {
    match err.downcast_ref::<OrderError>() {
        Some(OrderError::NotFound(id)) => println!("Hiển thị trang 404 cho đơn #{id}"),
        Some(other) => println!("Lỗi đơn hàng: {other}"),
        None => println!("Lỗi hệ thống: {err:#}"),
    }
}

downcast_ref vẫn hoạt động khi lỗi đã được bọc bằng .context(...). Tuy vậy, nếu bạn downcast thường xuyên, đó là dấu hiệu bạn nên dùng một enum thiserror thay vì anyhow.

6. Kết hợp: thiserror ở tầng dưới, anyhow ở tầng trên

Một quy tắc đơn giản và hiệu quả:

  • Library hoặc module domain dùng thiserror. Caller cần biết chính xác lỗi gì để quyết định hành động.
  • Binary, CLI, handler ở tầng ngoài cùng dùng anyhow. Ở đây bạn chủ yếu báo cáo lỗi.
use anyhow::{Context, Result};

fn process_order_file(path: &str) -> Result<()> {
    let quantity = load_quantity(path)
        .with_context(|| format!("xử lý file đơn hàng {path} thất bại"))?;
    println!("Đơn hàng có {quantity} sản phẩm");
    Ok(())
}

Hàm load_quantity trả về OrderError có cấu trúc. Tầng trên bọc thêm context về file đang xử lý. Khi chạy với file chứa -3, bạn sẽ thấy:

Error: xử lý file đơn hàng orders/1001.txt thất bại

Caused by:
    số lượng không hợp lệ: -3 (phải > 0)

7. Test hành vi lỗi

Kiểu lỗi có cấu trúc giúp việc test trở nên chính xác. Bạn có thể kiểm tra đúng variant, thay vì so sánh chuỗi một cách mơ hồ:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn negative_quantity_returns_invalid_quantity() {
        let err = parse_quantity("-3").unwrap_err();

        assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
        assert_eq!(err.to_string(), "số lượng không hợp lệ: -3 (phải > 0)");
    }

    #[test]
    fn non_numeric_input_returns_parse_error() {
        let err = parse_quantity("abc").unwrap_err();
        assert!(matches!(err, OrderError::Parse(_)));
    }

    #[test]
    fn missing_config_keeps_io_error_as_root_cause() {
        let err = read_port("/khong/ton/tai.conf").unwrap_err();

        assert_eq!(
            err.to_string(),
            "không đọc được file cấu hình /khong/ton/tai.conf"
        );
        assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
    }
}

Chạy test bằng lệnh:

cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok

test result: ok. 3 passed; 0 failed

Test thứ ba cho thấy root_cause() đi tới lỗi trong cùng của chuỗi. Nhờ đó bạn xác nhận được context không làm mất lỗi gốc.

8. Lỗi hay gặp và khi nào không nên dùng

Lưu ý: Đừng lặp lại thông điệp của lỗi gốc trong #[error]. Nếu bạn viết #[error("io error: {0}")] Io(#[from] io::Error), thông điệp gốc sẽ xuất hiện hai lần khi in cả chuỗi bằng {:?} hoặc {:#}. Hãy chọn một trong hai cách: nhúng {0} và không khai báo source, hoặc khai báo source và để thông điệp ngắn gọn.

Lưu ý: Không dùng anyhow::Error trong public API của library. Caller sẽ không thể match lỗi và buộc phải downcast. Hãy expose một enum thiserror thay vào đó.

Một số lỗi khác bạn có thể gặp:

  • Hai variant cùng #[from] một kiểu nguồn. Ví dụ, hai variant cùng bọc io::Error sẽ sinh ra hai impl From trùng nhau và gây lỗi biên dịch. Hãy dùng #[source] kết hợp map_err cho ít nhất một variant.
  • Lạm dụng #[from]. Nếu mọi io::Error đều thành OrderError::Io, bạn mất thông tin về thao tác nào đã thất bại. Variant có field context, như UpdateFailed { sku, cause }, thường hữu ích hơn.
  • Quên rằng anyhow::Error không implement std::error::Error. Nó deref sang dyn Error nhưng không implement trait đó. Bạn không thể truyền nó vào chỗ cần E: std::error::Error. Nếu cần, hãy bọc nó bằng một variant #[error(transparent)].
  • Gọi .context(format!(...)). Chuỗi được format ngay cả khi không có lỗi. Hãy dùng .with_context(|| ...).
  • Code cực kỳ nhạy hiệu năng hoặc no_std. anyhow::Error cấp phát trên heap. Với hot path hoặc môi trường embedded, một enum nhỏ gọn là lựa chọn tốt hơn.

Nếu muốn có backtrace, hãy đặt biến môi trường RUST_BACKTRACE=1 khi chạy. anyhow sẽ in backtrace kèm theo lỗi.

9. Kết luận

thiserror và anyhow giải quyết hai nhu cầu khác nhau. thiserror loại bỏ boilerplate khi định nghĩa kiểu lỗi, phù hợp với library và tầng domain, nơi caller cần phân loại lỗi. anyhow gom mọi lỗi về một kiểu, cho phép gắn context theo từng bước, phù hợp với binary và tầng ngoài cùng.

Bạn nên kết hợp cả hai: định nghĩa lỗi có cấu trúc ở dưới, bọc context ở trên. Cuối cùng, hãy giữ thông điệp ngắn gọn, không lặp lại lỗi gốc, và viết test kiểm tra đúng variant thay vì chỉ so sánh chuỗi.

rusterror-handlingthiserroranyhow