🦀

Rust の tonic で gRPC の Richer Error Model を扱う

2024/01/24に公開

この記事は Rust Advent Calendar 2023 の 10 日目の記事です.

はじめに

この記事ではバックエンドサービスの開発に Rust と gRPC を利用した際の知見を紹介します.

gRPC はクライアント・サーバー間でスキーマ駆動開発をするのに便利なツールですがエラーハンドリングに少し癖があります.

gRPC のエラーモデルにはシンプルだが柔軟性に欠ける Standard error model と柔軟だが互換性に欠ける Richer error model があります. Standard error model はどの言語のライブラリ実装も似通っていますが、Richer error model は標準仕様ではないためか、ライブラリ実装が微妙に違っていたりそもそもサポートされていなかったりします.

Rust で gRPC サービスを実装する際のデファクトとして protobuf codec の prost と gRPC 実装の tonic という crate があります.
これらの crate は Richer error model をサポートはしていますが、API がやや分かりにくく、gRPC の Richer error model について詳しく書かれたドキュメントや記事も少ないです.

そこでこの記事では gRPC エラーモデルの概要、そして Rust の tonic で gRPC の Richer error model を扱う方法をざっくりと説明します.

gRPC のエラーモデル

Standard error model

gRPC の Standard error model は status code とエラーメッセージからなるシンプルなエラーモデルです.

Rust の tonic では以下のようなコードになります. 特に詰まることもないでしょう.

let status = Status::unimplemented("I'm always procrastinating :)");

サーバーがこの status を返すとクライアントは Code: 12 と message: "I'm always procrastinating :)" を受け取ります.

Standard error modelはシンプルで様々なツールとの相性もいい反面エラーの詳細を RPC 呼び出し側に伝えることができません.

例えば、unavailable(14) を返した際に Retry-After の情報を返したり、バリデーションエラーでどのフィールドがどのように間違っているのか伝えたりできません.

Richer error model

gRPC には Standard error model より詳しいエラー情報を RPC 呼び出し側に伝えるための機能として Richer error model があります.

Richer error model を使うと、任意の protobuf の message をシリアライズしてエラーレスポンスのメタデータとして返すことができます.

ただし、gRPC の Richer error model を利用する際には以下の制約を意識する必要があります.

  • 各言語の gRPCライブラリの Richer error model 実装の互換性は保証されていない
  • プロキシ、ロガー、HTTP リクエスト処理系は gRPC の error details を読めないのでモニタリング時に情報が失われる
  • トレイラーに含まれる error details がhead of line blocking[1]を引き起こす. キャッシュミスが増加し、ヘッダー の圧縮効率が低下する.
  • ヘッダサイズの増加と欠損の可能性がある

Richer error model を使うと任意の message をシリアライズすることができますが、多くの言語の gRPC 実装では Google の Richer error model のパッケージが提供されています.
このパッケージの実装でほとんどの RPC のユースケースはカバーできるはずなので特殊な要件がない場合はこれを使うことを推奨します.

また、Google の GitHub に各言語向けのパッケージソースの proto ファイル があるのでこれらを利用してもいいでしょう.

Google の提供する Richer error model で満たせないユースケースがアプリケーションにある場合は自前のエラーモデルを protobuf で定義しコードを生成し、
そのエラーモデルをメタデータにシリアライズする必要があります.

Rust で gRPC Richer Error Model を使う

Rust の gRPC に関係する crate

Rust で gRPC を扱うには以下の crate を利用します.

  • prost: Rust の protobuf codec. Rust の型と protobuf message のシリアライズ・デシリアライズを提供する.
  • prost_types: protobuf で利用される汎用的な型(AnyTimestamp など)を集めた crate.
  • tonic: Rust の gRPC 実装. クライアントの実装とサーバーのインターフェースの自動生成.
  • tonic_types: gRPC で利用される汎用的な型を集めた crate. google.rpc パッケージ互換.

以降の内容は読者が上記の crate の README やドキュメントを斜め読みしていることを前提としています.

Google が提供する汎用的な Richer error model を利用する

Google が提供する汎用的な Richer error model を利用する場合は tonic-types を利用します.

エラーのデザイン(設計思想)については https://cloud.google.com/apis/design/errors#error_details が詳しいです.

任意の protobuf message を details にシリアライズする

Golang の gRPC パッケージでは StatusWithDetails 関数に proto から生成された型のインスタンスを渡すだけでカスタムエラーを付与できますが、
Rust で同じことをするにはやや煩雑な手順を踏まなければならないです.

// WithDetails に proto ファイルから生成された型を直接渡せる.
func (s *Status) WithDetails(details ...proto.Message) (*Status, error)

さて、これは Rust に限らない話ですが、主要な言語の gRPC 実装では gRPC Richer error model は gRPC の Statusdetails: Bytes フィールドに
Google の proto で定義された Status(Status とは別の型) をシリアライズする形式になっています.
これは厳密な仕様ではないですがデファクト実装になっています.

Rust では gRPC の Statustonic::Status に、Google の proto で定義された Statustonic_types::Status に対応しています.

Google の proto で定義された Status には protobuf の Any 型の配列の details フィールドがあります.

package google.rpc;

message Status {
  int32 code = 1;

  string message = 2;

  repeated google.protobuf.Any details = 3;
}

開発者は任意の protobuf message を Any にシリアライズし Status の details フィールドに入れることでカスタムエラーを RPC で送受信できるようになります.

Rust の prost(Rust の protobuf codec) では Any は prost_types::protobuf::Any に、 protobuf の message は prost::message::Message trait
が実装されている全ての型に対応しています.

protobuf の message にはその message を識別するための名前(正確にはtype url)が存在します. Any と message を相互に変換するためにはその名前が必要になります.

impl Any {
    pub fn from_msg<M>(msg: &M) -> Result<Self, EncodeError>
    where
        M: Name,
    ...

Rust では Any::from_msg は上のようなシグニチャになっているので prost::Name trait を message に実装すれば
message(ここでは Error 型) を Any 型に変換できます.

なお最新の prost ではコード生成時に Name trait を自動生成するオプションが生えています.

https://github.com/tokio-rs/prost/pull/931

impl prost::Name for crate::api::errors::v1::Error {
    const NAME: &'static str = "Error";

    const PACKAGE: &'static str = "errors.v1";
}
impl TryFrom<crate::api::errors::v1::Error> for prost_types::Any {
    type Error = EncodeError;
    fn try_from(value: crate::api::errors::v1::Error) -> Result<Self, Self::Error> {
        prost_types::Any::from_msg(&value)
    }
}

message を Any 型に変換できたら tonic_types::Status の details フィールドにそのエラーを詰められます.

let value:prost_types::Any = custom_error.try_into().unwrap();
let status = tonic_types::Status {
    code: code.into(),
    message: message.to_string(),
    details: vec![value],
};

prost の Message trait には encode_to_vec(): Vec<u8> 関数があります.
Google の proto から生成された tonic_types::Status にも Message trait が実装されているので
これを呼べば tonic::Status::with_details(code: Code, message: impl Into<String>, details: Bytes) に渡せる形に変換できます.

let details: Bytes = status.encode_to_vec().into();
let status = tonic::Status::with_details(code, message, details),

この status を gRPC サーバーから返せばクライアントに詳細なカスタムエラーを伝えることができます.

-proto にカスタムエラーを定義した proto ファイルを指定すればエラーレスポンスの details は grpcurl からも確認できます.

grpcurl \
-proto protobuf/my/rpc/definition.proto -proto protobuf/errors/v1/errors.proto \
$HOST:$PORT  my.rpc.definition/Call

details を protobuf message にデシリアライズする

シリアライズの逆をすれば details に入った protobuf message をデシリアライズできます.

以下のような Status を考えます.

let inner = tonic_types::Status {
    code: Default::default(),
    message: Default::default(),
    details: vec![custom_error_as_any],
};
let details = inner.encode_to_vec();
let outer = Status::with_details(
    tonic::Code::InvalidArgument,
    "The client sent an invalid argument",
    details.into(),
);

tonic_types::Status は details フィールドに、 Any は value フィールドに protobuf message を bytes にシリアライズした値を持っています.
protobuf message から生成された型には bytes をデコードする decode 関数があります. これを使って bytes を Rust の型に持ち上げることができます.


let roundtrip = tonic_types::Status::decode(outer.details()).unwrap();
// Error は proto ファイルから生成された型
let custom_error = roundtrip.details.first().map(|any| Error::decode(&*any.value));

クライアントから details の Any をデシリアライズする方法については https://ericb.xyz/posts/rust-tonic-grpc-errors/ を参考にしました.

おわりに

この記事で取り上げたように、2023年12月時点で gRPC の Richer error model を Rust で扱うにはやや煩雑な手続きが必要です.
また、Richer error model に関するドキュメントも少なく、その処理を実装するために GitHub の Issue を読んでまわったりライブラリの実装を読んだりする必要がありました.
prost や tonic のアップデートで Richer error model の取り扱いが改善されることを祈っています.

しかし、Rust は表現力が高く抽象度が高いコードを書けるため、protobuf や gRPC の仕様と prost や tonic コードの実装との差分が小さく、
ライブラリ実装のコードを読んだり挙動を推測したりする際のコードリーディングの負担が小さかったです.
また、実装する際にメンタルモデルが完全にできていない状態でもコンパイルが通ればコードが正しいだろう、と安心できるのも弊社で頻繁に利用されている Python や Golang にはない嬉しいポイントです.

プログラムは既にあるものを読んだり安全に変更したりするために使う時間の方が最初にコードを書く時間よりも圧倒的に多いので、
雑に書くのが難しく読んだり変更したりする際の負担が少ない Rust は継続的なチーム開発に向いていると思います.

余談ですが、Richer error model の実装を調べている途中で、Name traitでパッケージの名前と型の名前を結合する処理が逆になっているバグを見つけました(最新の prost では既に修正済み)

https://github.com/tokio-rs/prost/pull/923

「tokio-rs 配下のプロジェクトでもこんなバグが入ることがあるのか...🤔 」と驚いてしまいました.

さて、最後までお読みいただき、ありがとうございました. Rust で gRPC バックエンド開発したいよ!という方はこちらのスライドもぜひ目を通してみてください.

https://speakerdeck.com/i10416/rust-x-web-x-gcp-woyatuteiku

また、gRPC が利用する HTTP/2 の仕様の理解を深めるために HPACK の実装について紹介した記事もあります.

https://zenn.dev/110416/articles/1b0ebe8c024528

Refs

脚注
  1. head of line blocking は https://github.com/rmarx/holblocking-blogpost が詳しい. また head of line blocking と http/2、 QUIC(http/3)のモチベーションについては https://http3-explained.haxx.se/ja/h3/h3-streams が詳しい. ↩︎

Discussion