【Rust】thiserrorを使う場合どのようなエラー構造にするべきか?

2024/02/03に公開

現在meltosというサービスを個人開発しており、API サーバ側を Rust で書いています。
開発の中でthiserrorの使い方について考えさせられた点が多くあったため今回記事にしました。

現状の自分の中での回答

最初に自分が考えるエラー構造の理想を示します。
ただし、自分のthiserrorや rust そのものに対する理解が浅かったり、納得がいっていない点もあるため参考程度に捉えて貰えばいいと思います。

  1. エラーを細分化する(モジュールやエラーの種類ごと)
  2. #[from]の使用に注意する
  3. 実装者、またはユーザーに向けて適切なパラメータをエラーメッセージに組み込む
  4. なるべく PartialEq, Clone を実装できるようにする

thiserror を使う上での問題点

誤解を招かないように最初に言っておくと、thiserror 自体は豊富な機能が揃っており素晴らしいライブラリだと思います。
ただ豊富かつ強力すぎるが故に、考えずに誤った使い方をするとプロジェクトが肥大化するにつれてどんどん取り返しがつかなくなってしまう側面があると感じました。

では誤った使い方とはどのようなものかというと、例えば下記のようなエラーの定義の仕方です。

#[derive(Error, Debug)]
pub enum CustomError {
    #[error(transparent)]
    Http(#[from] reqwest::Error),

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

1.エラーの細分化

上記のCustomErrorを例に説明します。
std::io::Errorは I/O 関連のエラーを表します。
I/O 処理は様々なモジュールから使用されることが想定されますが、どのモジュールからエラーが吐かれてもCustomError::Ioに集約されてしまいます。

モジュール毎にエラーを分けないことによる問題はエラーの解析の難易度を上昇させるだけではなく、エラーを元に特定の構造体に変換することも不可能にします。

例えばエラーから HTTP レスポンスに変換する場合を考えます。
モジュール A とモジュール B があったとして A で失敗した場合はステータスコード 500, B は 400 を返したいとしても、どちらからもCustomError::Ioが返されてしまっている状態では変換できません。

例えば以下のようにモジュールごとにエラーを定義すればパターンマッチでステータスコードが振り分けられます。

#[derive(Error, Debug, PartialEq, Clone)]
pub enum CustomError {
    #[error("failed module a")]
    FailedModuleA,

    #[error("failed module b")]
    FailedModuleB,
}

impl From<CustomError> for axum::http::Response<Body>  {
    fn from(value: CustomError) -> Self {
        let status_code = match value {
            CustomError::FailedModuleA => StatusCode::INTERNAL_SERVER_ERROR,
            CustomError::FailedModuleB => StatusCode::BAD_REQUEST,
        };
        Response::builder()
            .status(status_code)
            .body(Body::empty()) 
            .unwrap()
    }
}

2.#[from]の使用に注意する

1.の問題ですが、根本的な原因は#[from]属性が付与されていることだと思います。
正直この属性はstd::io::Error等の、対象が広義に当たるようなエラーには使わないほうがいいと考えています。

例えば最初のCustomErrorを改良してモジュールごとにエラーを定義したとします。

下記のmodule_a関数は失敗した際にFailedModuleAを返し、module_bからはFailedModuleBを返すことを期待しますが、module_b内では?オペレーションを使用しているためCustomError::Io型として返されてしまっています。

    #[derive(Debug, Error)]
    enum CustomError {
        #[error("failed module a; io message: {0}")]
        FailedModuleA(#[source]std::io::Error),

        #[error("failed module b; io message: {0}")]
        FailedModuleB(#[source]std::io::Error),

        #[error(transparent)]
        Io(#[from] std::io::Error),
    }

    fn module_a(file_path: impl AsRef<Path>) -> std::result::Result<String, CustomError> {
        std::fs::read_to_string(file_path).map_err(CustomError::FailedModuleA)
    }

    fn module_b(file_path: impl AsRef<Path>) -> std::result::Result<(), CustomError> {
        std::fs::create_dir(file_path)?;
        Ok(())
    }

    #[test]
    fn e_is_failed_module_a() {
        let e = module_a("hello.txt")
            .unwrap_err();
            // これは通る
        assert!(matches!(e, CustomError::FailedModuleA(_)));
    }

    #[test]
    fn e_is_failed_module_b() {
        let e = module_b("dir")
            .unwrap_err();
            // これは失敗する
        assert!(matches!(e, CustomError::FailedModuleB(_)));
    }

3.実装者、またはユーザーに向けてパラメータをエラーメッセージに組み込む

thiserror はエラーメッセージにパラメータを組み込むことが出来ます。

例として、以下はリクエストユーザーの権限が足りなかった時のエラーで、パラメータにユーザー ID と Role を組み込んでいます。

#[derive(Error, Debug, PartialEq)]
pub enum ApiError {
    #[error("permission denied; user_id={0}, role={1}")]
    PermissionDenied(UserId, UserRole),
}

ただし、Error からエラーログに書き出したりレスポンスに変換したりする場合はプライバシーも考慮する必要があるため、ユーザー向けに出力する際にはPermissionDenied に UserId, UserRole を組み込むことはもしかしたら不適切かもしれません。
それでもテスト時には詳細な出力が欲しいという場合、次のように書くことが出来ます。

#[derive(Error, Debug, PartialEq)]
pub enum ApiError {
    #[cfg_attr(test, error("permission denied; user_id={0}, role={1}"))]
    #[cfg_attr(not(test), error("permission denied"))]
    PermissionDenied(UserId, UserRole),
}

4.なるべく PartialEq, Clone を実装できるようにする

これに関しては、テスト時に実装できるようにしておけばよかったと感じたため項目として追加しました。
テスト時には失敗時のケースも確認したいことがほとんどのため、下記のようにResult型から直接assert_eq!マクロで結果を確認できるのは可読性も高くなりますしお勧めですが、後述する問題もあります。

    #[derive(PartialEq, Debug, Error)]
    enum CustomError{
        #[error("overflow; limit: {limit}, actual: {actual}")]
        Overflow{
            limit: usize,
            actual: usize
        }
    }

    fn calc(lhs: usize, rhs: usize) -> std::result::Result<usize, CustomError>{
        let sum = lhs + rhs;
        if sum <= 10{
            Ok(sum)
        } else{
            Err(CustomError::Overflow {
                limit: 10,
                actual: sum
            })
        }
    }

    #[test]
    fn overflow_if_calc_result_is_11() {
        let result = calc(10, 1);
        assert_eq!(result, Err(CustomError::Overflow {
            limit: 10,
            actual: 11
        }))
    }

現状の問題点

例えば先ほど書いたModuleErrorではstd::io::Errorをソース元として保持するようにしていますが、std::io::ErrorPartialEqを実装していないため 3.の項目は満たせないです。というよりむしろ満たせるケースのほうが少ないのかとも思います。

一応下記のようにto_stringで文字列化した後の値を保持するようにすれば回避できますが、ソース元を正しく設定できなくなります。

    // 改変前
    #[derive(Debug, Error)]
    enum ModuleError {
        #[error("failed module a; io message: {0}")]
        FailedModuleA(#[source] std::io::Error)
    }

    // 改変後
    #[derive(Debug, Error, Clone, PartialEq)]
    enum ModuleError {
        #[error("failed module a; io message: {0}")]
        FailedModuleA(String)
    }

Discussion