🚨 Result thay cho panic trong luồng tiền

By Everwise Intern

Module 1 đã cho bạn domain model tài chính với ownership rõ ràng, lifetimes và traits để trừu tượng hóa. Nhưng câu hỏi lớn nhất của một hệ thống tiền tệ vẫn chưa được trả lời: khi tiền không đủ, khi account bị frozen, khi DB timeout, hàm của bạn trả về cái gì? Bài này mở Module 2 và trả lời đúng câu đó, trước khi ta bước sang async/Tokio ở bài sau.

Sau bài này, bạn sẽ:

  • Chỉ ra được các điểm panic nguy hiểm trong một hàm chuyển tiền.
  • Viết signature trả về Result<T, E> và hiểu ? làm gì ở mức desugar.
  • Phân biệt lỗi nghiệp vụ (business error) và lỗi hệ thống (system error) theo cách xử lý, không theo cách đặt tên.
  • Dùng thiserror cho domain crate và anyhow cho biên ứng dụng, không trộn lẫn.

Một dòng panic đủ để lệch sổ cái

Panic không phải "lỗi to". Nó là "tôi bỏ cuộc". Khi panic xảy ra giữa một giao dịch tiền, stack unwind và mọi thứ bạn đã mutate trước đó vẫn nằm nguyên như vậy. Không rollback, không ai biết đã trừ tiền hay chưa.

// ĐỪNG làm thế này trong production
pub fn transfer(from: &mut Account, to: &mut Account, amount: i64) -> Receipt {
    from.balance -= amount; // tiền đã bị trừ
    let new_balance = to
        .balance
        .checked_add(amount)
        .expect("balance overflow"); // panic: khoản trừ kia mất luôn
    to.balance = new_balance;
    Receipt { from: from.id, to: to.id, amount }
}

Ba điều tệ trong đoạn trên:

  1. expect làm chương trình chết ngay giữa giao dịch, tiền biến mất khỏi sổ mà không có bút toán đối ứng.
  2. Caller không thấy gì trong signature, nên không thể phòng bị.
  3. Nếu đây là async task, request đó chết, connection có thể bị treo, và log của bạn chỉ có một dòng panic không kèm account_id.

Các nguồn panic hay gặp trong code ngân hàng: unwrap/expect, integer overflow (debug build panic, release build wrap âm thầm, nguy hiểm hơn vì sai mà không báo), index vào slice, chia cho 0, Mutex::lock().unwrap() khi mutex bị poison.

Warning: -C overflow-checks=off là mặc định ở release profile. Với tiền, bạn không được để phép tính wrap. Luôn dùng checked_add, checked_sub, checked_mul, và bật lại overflow-checks = true cho crate tài chính.

Result là hợp đồng ở compile time

Result<T, E> buộc caller quyết định. Nó #[must_use], và signature tự nói lên điều gì có thể xảy ra. Đây là cách tôi muốn thấy mọi hàm trong luồng tiền:

#[derive(Debug, thiserror::Error)]
pub enum TransferError {
    #[error("amount must be positive, got {0}")]
    InvalidAmount(i64),
    #[error("insufficient funds: available {available}, requested {requested}")]
    InsufficientFunds { available: i64, requested: i64 },
    #[error("account {0} is frozen")]
    AccountFrozen(AccountId),
    #[error("balance overflow for account {0}")]
    BalanceOverflow(AccountId),
}

pub fn transfer(
    from: &mut Account,
    to: &mut Account,
    amount: i64,
) -> Result<Receipt, TransferError> {
    if amount <= 0 {
        return Err(TransferError::InvalidAmount(amount));
    }
    if from.status == Status::Frozen {
        return Err(TransferError::AccountFrozen(from.id));
    }

    // Tính hết giá trị mới trước, chưa động vào state
    let new_from = from
        .balance
        .checked_sub(amount)
        .ok_or(TransferError::InsufficientFunds {
            available: from.balance,
            requested: amount,
        })?;
    let new_to = to
        .balance
        .checked_add(amount)
        .ok_or(TransferError::BalanceOverflow(to.id))?;

    // Mutate sau cùng: từ đây không còn chỗ nào panic
    from.balance = new_from;
    to.balance = new_to;
    Ok(Receipt { from: from.id, to: to.id, amount })
}

Mẹo thực chiến: validate và tính xong hết, chỉ ghi state ở cuối. Cách này biến "rollback" thành "chưa từng ghi", và bạn không cần transaction compensate ở tầng logic.

? không phải phép thuật. Nó desugar thành match, và nhánh Err gọi _____ để chuyển kiểu lỗi, nên nó chỉ biên dịch được khi đã có impl From tương ứng.

Bài tập: tìm các điểm panic và sửa signature

pub fn withdraw(account: &mut Account, amount: i64) -> i64 {
    let limit = LIMITS.get(&account.tier).unwrap();
    assert!(amount < limit, "limit exceeded");
    account.balance -= amount;
    let fee = amount / 100; // amount = 0 thì ok, nhưng amount âm thì lãi ngược
    account.balance -= fee;
    account.balance
}

Gợi ý: có 3 điểm panic (get().unwrap(), assert!, và trừ trực tiếp có thể overflow) và 1 lỗi ngữ nghĩa (amount âm hoặc 0 làm fee sai). Sửa thành pub fn withdraw(account: &mut Account, amount: i64) -> Result<Balance, WithdrawError>, dùng .ok_or(WithdrawError::UnknownTier(account.tier))?, thay assert! bằng nhánh Err(WithdrawError::LimitExceeded { .. }), và checked_sub.

Lỗi nghiệp vụ và lỗi hệ thống: cùng là Err, khác cách xử lý

Đây là chỗ intern hay gộp hết vào một enum khổng lồ rồi trả 500 cho mọi thứ. Sai. Tiêu chí phân loại không phải "lỗi nặng hay nhẹ", mà là ai chịu trách nhiệm và có retry được không.

Tiêu chí Lỗi nghiệp vụ Lỗi hệ thống
Ví dụ InsufficientFunds, AccountFrozen, LimitExceeded DB timeout, pool exhausted, IO error, deadlock
Ai "gây ra" Dữ liệu/ý định của khách hàng Hạ tầng
Caller làm gì Từ chối, giải thích cho user Retry có backoff, hoặc fail an toàn
HTTP 400 / 409 / 422 500 / 503
Alert on-call Không Có
Log level info / warn error
Có tính phí/ghi sổ Không Không

Bài tập: phân loại và map sang HTTP

Cho các variant: InvalidAmount, InsufficientFunds, DuplicateRequestId, DbTimeout, ExchangeRateUnavailable, SigningKeyMissing. Hãy chia hai nhóm và gán status code.

Gợi ý: InvalidAmount (422), InsufficientFunds (422), DuplicateRequestId (409, nghĩa là idempotency key đã dùng, trả lại kết quả cũ nếu có). Còn DbTimeout (503), ExchangeRateUnavailable (503 hoặc 502 nếu phụ thuộc dịch vụ ngoài), SigningKeyMissing (500, cấu hình sai, drop everything và alert ngay).

thiserror cho domain, anyhow cho biên

Quy tắc tôi luôn dạy: nếu caller cần match để xử lý, error phải là enum có kiểu. Đó là thiserror trong domain crate.

// domain crate: kiểu lỗi rõ ràng, match được, không phụ thuộc hạ tầng
#[derive(Debug, thiserror::Error)]
pub enum LedgerError {
    #[error("posting unbalanced: debit {debit}, credit {credit}")]
    Unbalanced { debit: i64, credit: i64 },
    #[error(transparent)]
    Storage(#[from] StorageError),
}

Ở tầng handler hoặc main, bạn chỉ cần mang ngữ cảnh và log, gom nhiều loại lỗi khác nhau, không cần match từng variant. Đó là lúc anyhow tỏa sáng:

use anyhow::{Context, Result};

async fn settle_batch(batch_id: BatchId, repo: &Repo) -> Result<()> {
    let batch = repo
        .load_batch(batch_id)
        .await
        .with_context(|| format!("load batch {batch_id}"))?;
    repo.persist(&batch)
        .await
        .context("persist settled batch")?;
    Ok(())
}

Hai cấm kỵ: dùng anyhow::Error trong domain crate (bạn mất khả năng match variant và phá vỡ hợp đồng kiểu), và bọc lỗi nghiệp vụ vào anyhow rồi map tất cả sang 500 (khách hàng nhận "lỗi hệ thống" cho một giao dịch chỉ thiếu tiền).

? chỉ đẹp khi kiểu lỗi đã đúng

? tiện, nhưng nó cũng dễ che mất ngữ nghĩa. Ba thói quen cần bỏ:

  • .ok() hoặc unwrap_or_default() để "cho xong": nuốt lỗi, sổ cái lệch mà không ai biết. _____ là bug tài chính, không phải style.
  • map_err(|_| MyError::Unknown) làm mất toàn bộ thông tin gốc. Luôn giữ source: #[from] hoặc #[source].
  • Log rồi vẫn return Err, để tầng trên log lần nữa: ba dòng log cho một sự kiện, và on-call đếm sai số lỗi.

Khi cần đổi ngữ nghĩa, dùng map_err có chủ đích:

let rate = fx.rate(pair).await.map_err(|e| {
    if e.is_timeout() { TransferError::RateServiceUnavailable }
    else { TransferError::RateLookup(e) }
})?;

Những cạm bẫy tôi gặp nhiều nhất khi review code tiền

  • unwrap chỉ hợp lệ trong test hoặc khi bất biến đã được chứng minh bằng kiểu (ví dụ NonZeroU64). Trong handler: không bao giờ.
  • Tiền lưu bằng i64 đơn vị cents, không dùng f64. Mọi phép tính dùng checked_*, và biến đổi overflow thành lỗi nghiệp vụ có tên.
  • Mutex::lock().unwrap() trong luồng tiền là panic chờ sẵn khi có thread poison. Xử lý poison tường minh, đừng unwrap.
  • Error phải mang dữ liệu debug (account_id, amount, request_id) nhưng không mang số tài khoản đầy đủ, token hay PII vào log.
  • Đừng panic trong Drop và đừng panic! để "fail fast" ở tầng API. Fail fast là việc của test, không phải của giao dịch đang chạy.

Chốt lại

  • Panic giữa luồng tiền để lại state nửa vời và không có rollback. Result đưa quyết định đó về compile time.
  • Validate và tính trước, ghi state sau cùng: không cần compensate.
  • Lỗi nghiệp vụ trả 4xx, không retry, không alert; lỗi hệ thống retry có backoff và alert.
  • thiserror cho domain, anyhow cho biên, ? chỉ dùng khi From đã đúng nghĩa.

Bài sau ta giữ nguyên tinh thần này nhưng ở tầng khó hơn: async/await, Tokio và cách chia sẻ state an toàn giữa các task mà không cần Arc<Mutex<>> bừa bãi.

Front: Vì sao panic nguy hiểm trong luồng tiền? Back: Unwind giữa giao dịch, không rollback, state nửa vời và caller không thấy trong signature.

Front: Integer overflow trong debug build và release build khác nhau thế nào? Back: Debug: panic. Release: wrap âm thầm, sai mà không báo.

Front: Result<T, E> khác gì một giá trị bình thường? Back: Là enum Ok/Err có #[must_use], buộc caller xử lý cả hai nhánh.

Front: Toán tử ? desugar thành gì? Back: match: Ok thì lấy giá trị, Err thì return sớm qua From::from để chuyển kiểu lỗi.

Front: Khi nào dùng thiserror? Back: Trong domain/library, khi caller cần match từng variant lỗi.

Front: Khi nào dùng anyhow? Back: Ở biên ứng dụng (handler, main) khi chỉ cần gom ngữ cảnh và log.

Front: Vì sao không dùng anyhow::Error trong domain crate? Back: Mất khả năng match variant, phá vỡ hợp đồng kiểu của domain.

Front: InsufficientFunds là lỗi gì, trả HTTP nào? Back: Lỗi nghiệp vụ, 422, không retry, không alert on-call.

Front: DB timeout là lỗi gì và xử lý ra sao? Back: Lỗi hệ thống: retry có backoff, trả 503, có alert.

Front: Thứ tự đúng khi cập nhật balance là gì? Back: Validate và tính hết giá trị mới trước, chỉ mutate state ở cuối.

Front: Vì sao không dùng .ok() để bỏ qua lỗi? Back: Nuốt lỗi, mất dấu vết, sổ cái lệch mà không ai biết.

Front: Kiểu dữ liệu nào dùng cho tiền? Back: i64 đơn vị cents, kèm checked_add/checked_sub/checked_mul, không dùng f64.

Front: Rủi ro của Mutex::lock().unwrap() trong luồng tiền? Back: Mutex bị poison thì panic lan ra giữa giao dịch; phải xử lý poison tường minh.

Bạn vừa hoàn thành phần lý thuyết nền cho error handling trong Module 2. Trước khi sang bài async/Tokio, hãy thử rà lại một hàm trong project capstone của bạn và đổi mọi unwrap thành Result có tên lỗi cụ thể.