gRPC ServerのエラーをInterceptorでgRPC Error Statusにマッピング(Go言語版)
電通デジタルでバックエンドの開発をしている齋藤です。弊社では社内/グループ会社向けデジタル広告運用実績の管理システムバックエンド API に gRPC を利用しています。
本記事では gRPC サーバ内で発生したエラーを gRPC Server Interceptor で gRPC Error Status にマッピングする試作をご紹介します。
ユースケース
gRPC を使った API サーバでエラーが発生した場合、エラーハンドリングとロギングをした後に gRPC クライエントへのエラーを伝えます。ここで、サーバのエラーを gRPC クライアントに伝えるエラーに変換する処理が発生します。クライアントに伝えるエラーメッセージはサーバで発生したことそのままではなく、クライアントに必要な情報にする必要があります。この処理は全ての gRPC のメソッド呼び出しで実施します。そこで、本処理を gRPC Server Intercepter を使ってメソッド呼び出し後に実施することで、処理の共通化を試みます。図にすると以下のような変更になります。
今回は gRPC リクエストのパラメータが不適切な INVALID_ARGUMENT の場合の処理を具体例として上げていきます。なお、Unary API を対象とし、実装には Go 言語を使用します。
gRPC のステータス
gRPC にも HTTP と同じようにステータスコードが存在するので、ステータスコードも設定します。以下の Proto ファイルに定義と該当する HTTP ステータスが記載されています。
・Protocol BufferファイルでのgRPCステータスコードの定義
例えばリクエストに対するリソースが存在しない場合は HTTP の場合の 404 に該当する NOT_FOUND = 5 をセットします。今回の例の場合は INVALID_ARGUMENT = 3 をセットすることになります。
なお、gRPC サーバで何もステータスを指定しなかった場合
・エラーなし: OK = 0
・エラーあり: UNKNOWN = 2
のステータスが返却されることになります。
Go 言語では grpc/grpc-go (go get では google.golang.org/grpc )の
・status.Error / status.Errorf
・codes.Code
を使ってステータス付きのエラーを表現します。ステータスがINVALID_ARGUMENTの場合は以下のようなコードになります。
status.Errorf(codes.InvalidArgument, "some format", some_variable...)
エラーのパッケージ/生成/判別
Go 1.13 から Proposal: Go 2 Error Inspection のバックポートで、これまで pkg/errors などで行なっていたエラーのラッピングが標準の error で実施できるようになりました。しかし、スタックトレースの表示の標準の error への採用は見送りとなり、標準外の xerrors パッケージとして提供されることになりました。今回はこちらの xerrors を利用します
基本的な方針としては
・カスタムのエラー構造体を作成
・エラーをWrapして呼び出し元に戻す
・エラーの種類を判別してステータスにマッピング (これを gRPC interceptor で実施。詳細は次節)
とします。
カスタムのエラー構造体作成
Go の error インターフェースは Error関数の実装が必要です。これに加えて、fmt.Printf などのフォーマット付き出力で出力するために Format および FormatError 関数の実装も追加で必要となります。(今回は割愛しますが、必要に応じて Is / As なども実装することになります)。
今回はリクエストパラメータのバリデーションに失敗した場合を想定して CustomInvalidArgumentsError を作成します。実装は以下のようになります。
import (
"fmt"
"golang.org/x/xerrors"
)
// カスタムエラー構造体
// 今回は全公開していますが、実際には構造体/フィールドの公開範囲の変更の必要があります
type CustomInvalidArgumentsError struct {
Err error
Args map[string]string // invalid だった引数のKey-Valueを保存
Frame xerrors.Frame // スタックトレースの階層用
}
// error.Error() で生成するエラーメッセージを作成
// key1=value1, key2=value2, ... のようなメッセージを出力
func (e *CustomInvalidArgumentsError) Error() string {
var args []string
for k, v := range e.Args {
args = append(
args,
fmt.Sprintf("%s=%s", k, v),
)
}
msg := fmt.Sprintf("invalid arguments: %s", strings.Join(args, ", "))
return msg
}
// カスタムエラーの Unwrap
// ラップされたエラーをたどるために必要
func (e *CustomInvalidArgumentsError) Unwrap() error {
return e.Err
}
// 以下2関数を合わせてフォーマット付き出力時のメッセージを作成
// スタックトレースを出力するために利用
// ※ xerrors での実装をそのまま使っています
func (e *CustomInvalidArgumentsError) Format(s fmt.State, v rune) {
xerrors.FormatError(e, s, v)
}
func (e *CustomInvalidArgumentsError) FormatError(p xerrors.Printer) error {
p.Print(e.Error())
e.Frame.Format(p)
return e.Err
}
エラーのWrap
上記で作成したカスタムエラーを以下のようにしてWrapします。
err = &CustomInvalidArgumentsError{
Err: baseErr,
Args: args,
Frame: xerrors.Caller(0),
}
エラー種類の判別
今回は xerrors.As を使って以下のように判別します。
var errInvalidArgument *CustomInvalidArgumentsError
if xerrors.As(err, &errInvalidArgument) {
// invalid argument のときの処理
}
gRPC Server Interceptor でメソッド呼び出し後に処理をする
以前の記事 でも触れましたが、gRPC Interceptor は処理の前後に処理を挟み込む機能です。前回はメソッドの呼び出し前に処理をする例でしたが、今回はメソッドの呼び出し後に処理を実施します。下のコードの (1) の箇所になります。
func transmitStatusInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// メソッドより前に呼ばれる処理
// メソッドの処理
// 今回はこの err を判定していく
m, err := handler(ctx, req)
// メソッドの処理後に呼ばれる処理 ... (1)
// レスポンスを返す
return m, err
}
(1)の部分に以下のような処理を追加します。
if err != nil {
log.Printf("error: %+v", err) // スタックトレースを出力
err = convertErrorWithStatus(err) // ステータス付きのエラーに変換。後述
}
convertErrorWithStatus は以下のような実装になります。
func convertErrorWithStatus(err error) error {
var errWithStatus error
var errInvalidArgument *CustomInvalidArgumentsError
// 実際には想定するエラー分分岐が必要
if xerrors.As(err, &errInvalidArgument) {
// gRPC status の INVALID_ARGUMENT を返却
errWithStatus = status.Errorf(
codes.InvalidArgument, "%s", err.Error())
} else {
errWithStatus = status.Error(
codes.Unknown, "something occurred in server")
}
return errWithStatus
}
interceptor の実装をまとめると以下になります。
import (
"context"
"log"
"golang.org/x/xerrors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func transmitStatusInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
m, err := handler(ctx, req)
if err != nil {
log.Printf("error: %+v", err) // スタックトレースを出力
err = convertErrorWithStatus(err) // ステータス付きのエラーに変換
}
return m, err
}
func convertErrorWithStatus(err error) error {
var errWithStatus error
var errInvalidArgument *CustomInvalidArgumentsError
if xerrors.As(err, &errInvalidArgument) {
errWithStatus = status.Errorf(
codes.InvalidArgument, "%s", err.Error())
} else {
errWithStatus = status.Error(
codes.Unknown, "something occurred in server")
}
return errWithStatus
}
まとめ
本記事では gRPC サーバ内で発生したエラーを gRPC Server Interceptor で gRPC Error Status にマッピングする試作をご紹介しました。Interceptor の名称にも付けたのですが、本試作は grpc-java の TransmitStatusRuntimeExceptionInterceptor を元のアイディアにしています。
複数の言語で利用できる gRPC ですが、言語ごとの実装の状況やエコシステムの発展具合、言語特有の事情などで共通化されているものに差がある状態です。使えそうなアイディアを見つけたらまた、本ブログでご紹介したいと思います。