RustでWeb APIを作る際のエラーハンドリング

Drawer Growth グループ バックエンドエンジニアの中野です。今回は、私が所属するチームで gRPC API を開発する際に実践している Rust でのエラーハンドリングについて紹介していきます。

TL;DR

  • エラーの発生原因がわかるようにエラー型を定義することが大切。
  • anyhow は使わずに自前のエラー型を定義して利用する。

エラーハンドリングを行う目的

そもそもなぜエラーハンドリングを行う必要があるのでしょうか。私が所属するチームでは、以下目的を達成するためにエラーハンドリングを行っています。

  • 発生したエラーに関する情報をログに含めて、調査しやすくするため。
  • API の利用者に適切なレスポンスを返すため。

エラーハンドリングが適切に行われているとどう嬉しいか

エラーハンドリングが適切に行われている場合、我々は以下のような出力をログに埋め込むことができるようになります。

DrawingServiceError
* DrawingPageUseCaseError
* DrawingPageError
* Failed to cast 0 to PageNumber. PageNumber should be greater than 0.
* out of range integral type conversion attempted

実行可能なコードはこちら。 エラーハンドリングを適切に行なった場合に嬉しいポイントは2つあります。

1. エラーの発生原因が分かる

調査の際に「該当エラーはどの経路を通ってなぜ発生したのか」がログからすぐにわからないと辛いです。例えばどの API が呼び出されて発生したエラーなのか、コードベースにおけるどのレイヤーで発生したエラーなのか、といった情報がログを見るだけでわかると調査がスムーズに進みます。 エラー発生元のメッセージを見てみます。

* Failed to cast 0 to PageNumber. PageNumber should be greater than 0.
* out of range integral type conversion attempted

この出力がポイントで、このメッセージを見るだけで、「PageNumber は 0 より大きい数字である必要があるが、0 を PageNumber にキャストしようとして失敗した」という情報を得ることができます。 これがもし、以下のようなメッセージだと、ログを見るだけでは何が問題だったのかわからなくなり、エラーハンドリングを行なう旨みが半減してしまいます。

// 悪い例1
// `std::num::TryFromIntError`に定義されたメッセージだけが出力される
* out of range integral type conversion attempted

// 悪い例2
// 0を何にキャストしようとして失敗したのか」がわからないメッセージが出力される
* Failed to cast 0
* out of range integral type conversion attempted

これらの例を見るとわかるように、エラーメッセージを定義する際にはアプリケーションにおける文脈をエラーメッセージに残すことが大切です。

(補足)stack trace を出力することでもエラーの発生経路はわかります。しかし、以下の技術的な理由から私たちのチームでは stack trace を出力することをやめました。

  • チーム開発としてどこでエラーログを出力するのかポリシーを決めるのが難しかった。
  • stack trace を出すにはエラーが発生した箇所でログを出力する必要があるが、ログ出力するコードを書くのを忘れる可能性がある。

そのため、stack trace が本来持っている役割の一部をエラーメッセージに持たせる設計としています。

2. レスポンスステータスを型安全に出し分けることが可能になる

何かエラーが発生し、API 呼び出しが失敗した場合には、発生したエラーによって ステータスコード を出し分ける必要があります。エラーを型で表現していると、このステータスコードの出し分けを型安全に行うことができて嬉しいです。 後ほど具体的に紹介する方法を使うと、エラー型を新しく定義するたびにエラーをステータスコードに変換する箇所でコンパイルエラーが発生し、エンジニアにステータスコードの定義を強制させることができます。そのためステータスコードへの変換漏れや意図しないステータスコードに変換されてしまう可能性をなくすことが可能です。これによって、問題に気づくのがランタイムからコンパイル時にシフトレフトでき、エラーを定義するする手間を考えてもトータルの開発スピードを向上させることができると考えています。

どうエラーハンドリングを行うのか

実装方法

必要なエラー型を Enum で定義していきます。この際、 実装を簡略化するためにthiserrorという crate を用いています。thiserror::Errorを derive すると、自分で実装しなくてもコンパイル時に std::error::Error trait が実装され楽をできます。 以下はDrawingUseCaseで利用するためのエラー型の例です。 #[from] attribute をつけると From trait が実装されるので、?演算子を使ってエラーハンドリングしていくことが可能になります。 このようなエラー型を我々のチームでは基本 trait 毎に定義するようしています。

use thiserror::Error;

#[derive(Debug, Error)]
pub enum DrawingUseCaseError {
    #[error("DrawingRepository")]
    DrawingRepository(#[from] DrawingRepositoryError),
    #[error("CompanyRepository")]
    CompanyRepository(#[from] CompanyRepositoryError),
}

// DrawingUseCase trait
pub trait DrawingUseCase {
    async fn create_drawing(
          &self,
          command: CreateDrawingCommand,
    ) -> Result<Drawing, DrawingUseCaseError>;
}

// create_drawingの実装例
fn create_drawing(
        &self,
        command: CreateDrawingCommand,
) -> Result<Drawing, DrawingUseCaseError> {
    // この関数は Result<Company, CompanyRepositoryError>を返り値に持つ
    // CompanyRepositoryErrorに対して#[from] attributeがつけられているので、
    // ?演算子でDrawingUseCaseErrorへの変換が可能になっている。
    let company = get_company_by_name(command.company_name())?;
    ...
    drawing
}

エラーを伝播させていくと、最終的には API のエンドポイントとなる箇所において自分たちで定義したエラー型をtonic::Statusに変換する必要があります。このマイクロサービスの実装では、gRPC サービスを実装する際のデファクトであるtonicという crate を利用しています。別の crate を利用している場合には、tonic::Statusの箇所を適宜ステータスコードを表す別の型に読み替えてください。 この変換処理を行うために、定義したエラー型をtonic::Statusに変換するToErrorStatus trait とErrorHandlerstruct を定義します。

trait ToErrorStatus {
    fn build(self, error_message: String) -> tonic::Status;
}

struct ErrorHandler<'a, Error: std::error::Error>(&'a Error);

そして ToErrorStatus trait をこれまで定義してきたエラー型に対してそれぞれ実装していきます。ここではDrawingUseCaseErrorに対するToErrorStatusの実装だけを例に出していますが、同様にその他のエラー型に対してもToErrorStatusを実装していく必要があります。例えば、ErrorHandler(repository_error).build(error_message)が呼び出されているので RepositoryError型にもToErrorStatusを実装する必要があります。

impl ToErrorStatus for ErrorHandler<'_, DrawingUseCaseError> {
    fn build(self, error_message: String) -> Status {
        use DrawingUseCaseError::*;

        match self.0 {
            DrawingRepository(DrawingRepositoryError::Repository(repository_error)) => {
                ErrorHandler(repository_error).build(error_message)
            }
            DrawingRepository(DrawingRepositoryError::ParseDrawingId(_)) => {
                Status::with_error_details(Code::Internal, error_message, ErrorDetails::new())
            }
            CompanyRepository(CompanyRepositoryError::Repository(repository_error)) => {
                ErrorHandler(repository_error).build(error_message)
            }
        }
    }
}

最後に、以下のような関数を定義して、gRPC API のエンドポイントとなるResult<tonic::Response<HogeResponse>, tonic::Status>を返り値とする関数内で呼び出せるようにします。あとはエンドポイントとなる関数内でエラーハンドリングを行う際に、必要に応じてto_error_status関数を呼び出せば OK です。

pub(crate) fn to_error_status(error: impl Into<GrpcServiceError>) -> Status {
    use GrpcServiceError::*;

    let error: GrpcServiceError = error.into();
    let error_message = error.to_traverse_error_message();

    let mut status = {
        let error_message = error_message.clone();
        match &error {
            DrawingService(service_error) => {
                ErrorHandler(service_error).build(error_message)
            }
            CompanyService(service_error) => {
                ErrorHandler(service_error).build(error_message)
            }
        }
    };

    status.set_source(Arc::new(error));

    // 任意の方法でログを出力する
    // println!("{error_message}");

    status
}

#[derive(Debug, Error)]
pub(crate) enum GrpcServiceError {
    #[error("DrawingService")]
    DrawingService(#[from] DrawingServiceError),
    #[error("CompanyService")]
    CompanyService(#[from] CompanyServiceError),
}

このコードで利用しているto_traversal_error_messageメソッドはエラーの source を辿って全てを 1 つの String にまとめるための関数です。以下のエラーメッセージはto_traverse_error_messageメソッドを利用して出力した例です。

DrawingServiceError
* DrawingPageUseCaseError
* DrawingPageError
* Failed to cast 0 to PageNumber. PageNumber should be greater than 0.
* out of range integral type conversion attempted

to_traverse_error_messageメソッドを利用しない場合、 DrawingServiceErrorだけが出力され、DrawingServiceErrorの source を辿ったそれ以下の出力はなくなります。to_traverse_error_messageメソッドの実装はこちらにあるので、気になる方は確認してみてください。 このメソッドと同様のことはanyhowの debug 出力でも可能ですが、以下の理由から自前で関数を実装しています。

  • anyhow で wrap するのが面倒だった。
  • anyhow の他の機能は不要で debug 出力だけが欲しかった。
  • anyhow を使いたくなかったので、間違えて利用することが無いように crate から依存を外したかった。

エラー型の定義で気を付けるべきポイント

気を付けるべきポイントとして「エラー型を共通化しないこと」が挙げられます。我々のチームの場合、Infra 層で利用している crate であるsea-ormが返すエラーをマッピングしているRepositoryError型以外は基本的に共通化せず個別で定義するようにしています。そのため、例えば以下のように、2 つの別の UseCase のエラーの variants の中身がほぼ同じになることもあり得ます。

// 良い例
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DrawingUseCaseError {
    #[error("DrawingRepository")]
    DrawingRepository(#[from] DrawingRepositoryError),
    #[error("CompanyRepository")]
    CompanyRepository(#[from] CompanyRepositoryError),
}

#[derive(Debug, Error)]
pub enum SalesUseCaseError {
    #[error("DrawingRepository")]
    DrawingRepository(#[from] DrawingRepositoryError),
    #[error("SalesRepository")]
    SalesRepository(#[from] SalesRepositoryError),
}

エラー型の variants に重複が増えてくると、つい「DrawingUseCaseErrorSalesUseCaseErrorを統合してUseCaseErrorにしてしまおう」という誘惑に駆られるのですが、それはあまりいいアイデアではありません。

// よくない例
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UseCaseError {
    #[error("DrawingRepository")]
    DrawingRepository(#[from] DrawingRepositoryError),
    #[error("CompanyRepository")]
    CompanyRepository(#[from] CompanyRepositoryError),
    #[error("SalesRepository")]
    SalesRepository(#[from] SalesRepositoryError),
}

なぜなら、そうしてしまうと前述したエラーハンドリングを適切に行なった場合の嬉しいポイントである「何が原因でエラーが発生したのかが分かる」が失われてしまうからです。UseCaseError以外も全て共通化してしまった場合、ログの出力は以下のようになり、「0 を PageNumber にキャストしようとして発生したエラーである」ことしかわからなくなってしまいます。

ServiceError
* UseCaseError
* DomainError
* Failed to cast 0 to PageNumber. PageNumber should be greater than 0.
* out of range integral type conversion attempted

なぜanyhowを利用しないのか

Rust でエラーハンドリングを行う際によく名前が挙がる crate として anyhow がありますが、上記説明の通り我々は anyhow を利用していません。 anyhow の利用例はリポジトリのサンプルを見るとよくわかります。anyhow を利用すると、関数の返り値をanyhow::Result<i32>のように定義することで関数内では?演算子を使うだけでよくなり、自分でエラー型を定義する手間が省けます。一時的に利用するだけのスクリプトを書く際や利用者が限られる開発者用ツールを作る際など、エラー型を厳密に定義する必要がなく、anyhow を使うと楽に実装できるケースも多々あります。しかし、我々のケースのようにクライアントから利用される Web API を作る場合にはステータスコードの出し分けが必要であるはずですし、運用のために適切なログも必要になるはずです。この場合多少面倒でも、自分自身でエラー型を定義した方が型安全でデバッグに有益なコードを書くことができ、トータルの生産性は高くなると考えています。

実は以前、弊社で別のアプリケーションにおける Web API を作る際に anyhow を用いて実装したことがあったのですが、とても辛い結果になったという過去があります。具体的な辛いポイントとしては以下の要素などが挙げられます。

  • context を引き回すために常に with_contextをつけて回らなければいけない。
  • ログを見てもエラーの発生箇所を示すだけで原因がわからない。
  • ステータスコードの出し分けがエラーメッセージの文字列に頼るしかない。

エラーハンドリングを行う上で持っている課題感

ステータスコードの出し分けをする箇所の実装がごちゃつくことを現状の課題感として持っています。コードベースの成長に伴い UseCase や Infra、Domain 層の種類も増え、ToErrorStatusを実装するコードがどんどん肥大化して見通しが悪くなってきます。型で守ることができているとはいえ、ここはもう少し上手くやる方法がないか頭を悩ませているところです。