見出し画像

gRPC ServerのエラーをInterceptorでgRPC Error Statusにマッピング(Go言語版)

電通デジタルでバックエンドの開発をしている齋藤です。弊社では社内/グループ会社向けデジタル広告運用実績の管理システムバックエンド API に gRPC を利用しています。

本記事では gRPC サーバ内で発生したエラーを gRPC Server Interceptor で gRPC Error Status にマッピングする試作をご紹介します。

ユースケース

gRPC を使った API サーバでエラーが発生した場合、エラーハンドリングとロギングをした後に gRPC クライエントへのエラーを伝えます。ここで、サーバのエラーを gRPC クライアントに伝えるエラーに変換する処理が発生します。クライアントに伝えるエラーメッセージはサーバで発生したことそのままではなく、クライアントに必要な情報にする必要があります。この処理は全ての gRPC のメソッド呼び出しで実施します。そこで、本処理を gRPC Server Intercepter を使ってメソッド呼び出し後に実施することで、処理の共通化を試みます。図にすると以下のような変更になります。

画像1

今回は 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 ですが、言語ごとの実装の状況やエコシステムの発展具合、言語特有の事情などで共通化されているものに差がある状態です。使えそうなアイディアを見つけたらまた、本ブログでご紹介したいと思います。

9