Carpe Diem

備忘録

gRPCでエラー詳細を渡す方法

概要

以前

christina04.hatenablog.com

こちらの記事で、アプリケーション内でのレイヤ間のエラーハンドリングについてまとめました。
ではマイクロサービス間でそのエラーコードを伝播していくのはどうすれば良いのか、というのが今回の主題です。

課題

gRPCはレスポンスコードを持っています
しかしこれだけでは下記のようなケースをハンドリングできません。

  • フォームのvalidationエラーを伝える際に、どのフィールドの不備が原因か
  • カード決済時のエラーで、カードの何が問題でエラーが起きているのか

このような詳細なエラーをクライアントに伝えられない場合、クライアントは抽象的なエラー文言しかユーザに出せず、結果としてユーザは問題を解決することができなくなります。

解決案1) エラー文言をparse

gRPCは以下のようにレスポンスコード以外にもメッセージ(文字列)を返すことが可能です。

msg := "code: name_field_error, msg: validation error occurred"
return nil, status.Error(codes.InvalidArgument, msg)

それを使ってマイクロサービス間で文字列のフォーマットを決め、ハンドリングしていくという方法です。

問題点

しかしながら

  • 文字列比較のtypo可能性
  • ルールの徹底が必要

といった点から、運用が辛くなることが分かります。

解決案2) response bodyに独自のエラーコードを入れるフィールドを追加

facebookのGraph APIは、エラーレスポンスのbodyが以下のようになっており、その中にcodeというエラーコードが含まれています。

{
  "error": {
    "message": "Message describing the error", 
    "type": "OAuthException", 
    "code": 190,
    "error_subcode": 460,
    "error_user_title": "A title",
    "error_user_msg": "A message",
    "fbtrace_id": "EJplcsCHuLu"
  }
}

エラーコードの一部

ref: エラーを処理する - グラフAPI - ドキュメンテーション - Meta for Developers

このようなアプリケーション用のコードを、gRPCのレスポンスにも含めてしまうイメージです。

service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string code = 1;
  string message = 2;
}

codestatusといったフィールド名になると思います。

問題点

しかしながら

  • 全レスポンスに統一して埋め込むのが大変
  • 偶然同じフィールド名が使いたいユースケースで使えなくなる

という問題が起きます。

解決案3) error details protoを使う

ではどうするかというと、公式が用意してくれています。

This model enables servers to return and clients to consume additional error details expressed as one or more protobuf messages. It further specifies a standard set of error message types to cover the most common needs (such as invalid parameters, quota violations, and stack traces). The protobuf binary encoding of this extra error information is provided as trailing metadata in the response.

https://grpc.io/docs/guides/error/

statusにはStatus.WithDetailsというエラー詳細を埋め込む実装が用意されています。

この関数に公式が用意していくれているerror_details.protoや、独自のerror詳細protoを用意して埋め込むことで、詳細なエラーを表現することが可能です。

errdetailsの基本的な使い方

Goではerrdetails packageとして用意されています。

埋め込み方(サーバ側)

このようにStatusにセットできます。

st := status.New(codes.InvalidArgument, "some error occurred")
v := &errdetails.BadRequest{
        FieldViolations: []*errdetails.BadRequest_FieldViolation{
                {
                        Field:       "username",
                        Description: "should not empty",
                },
        },
}
dt, _ := st.WithDetails(v)
return nil, dt.Err()

ポイント

  • WithDetails()で追加する
  • 引数はMessage型(=protoのmessageなら何でもOK)
  • Detailsはsliceになっているので複数追加できる
  • 最期はErr()でエラーとして返す

取り出し方(クライアント側)

以下のように取り出してハンドリングします。

st := status.Convert(err)
for _, detail := range st.Details() {
    switch t := detail.(type) {
    case *errdetails.BadRequest:
        fmt.Println("Oops! Your request was rejected by the server.")
        for _, violation := range t.GetFieldViolations() {
            fmt.Printf("The %q field was wrong:\n", violation.GetField())
            fmt.Printf("\t%s\n", violation.GetDescription())
        }
    }
}

ポイント

  • 最初に通常のerrorからstatus.Statusに変換
  • Detailsはsliceになっているのでforでそれぞれ取り出す
  • type assertionでdetail Messageの場合分け

他のMessage

他のMessageに関しては以下の記事でコピペを用意してくれています。

gRPCのstatusとerrdetails Go言語版コピペ用 #Go - Qiita

独自エラーproto

次は独自のエラーメッセージでハンドリングするケースを紹介します。

proto

Error messageと、エラーコード用のEnumを用意しておきます

enum ErrorCode {
  UNKNOWN         = 0;
  INVALID_RECEIPT = 1;
  EXPIRED_RECEIPT = 2;
  INVALID_CARD    = 3;
  EXPIRED_CARD    = 4;
  INVALID_COUNTRY = 5;
  PREPAID_CARD    = 6;
}

message ErrorDetail {
  ErrorCode code    = 1;
  string    message = 2;
}

サーバ

ErrorCode_EXPIRED_RECEIPTを使いたい場合は以下のように追加しますし、

st := status.New(codes.InvalidArgument, "some error occurred")
dt, _ := st.WithDetails(&pb.ErrorDetail{Code: pb.ErrorCode_EXPIRED_RECEIPT})
return nil, dt.Err()

ErrorCode_INVALID_COUNTRYを使いたいときは以下のように追加します。

st := status.New(codes.InvalidArgument, "some error occurred")
dt, _ := st.WithDetails(&pb.ErrorDetail{Code: pb.ErrorCode_INVALID_COUNTRY})
return nil, dt.Err()

クライアント

各エラーケースをtype assertionでハンドリングします。

st, _ := status.FromError(err)

for _, detail := range st.Details() {
        switch t := detail.(type) {
        case *errdetails.BadRequest:
                fmt.Println("handle BadRequest case")
        case *errdetails.QuotaFailure:
                fmt.Println("handle QuotaFailure case")
        case *pb.ErrorDetail:
                // handle original error code
                fmt.Println("error code:", t.Code)
        }
}

検証

すると以下のようにエラー毎に詳細にハンドリングできるようになります。

error-details $ go run client/main.go
error code: EXPIRED_RECEIPT
2019/09/05 08:00:07 rpc error: code = InvalidArgument desc = some error occurred
exit status 1

error-details $ go run client/main.go
2019/09/05 08:00:08 Reply:  Hello alice

error-details $ go run client/main.go
handle BadRequest case
2019/09/05 08:00:10 rpc error: code = InvalidArgument desc = some error occurred
exit status 1

error-details $ go run client/main.go
error code: INVALID_COUNTRY
2019/09/05 08:00:12 rpc error: code = InvalidArgument desc = some error occurred
exit status 1

error-details $ go run client/main.go
handle QuotaFailure case
2019/09/05 08:00:13 rpc error: code = InvalidArgument desc = some error occurred
exit status 1

独自protoの場合、protocのpluginに注意

注意として公式のprotoc-gen-goコンパイルした時は問題ありませんでしたが、protoc-gen-gogoで独自protoをコンパイルしたところtype assertionやキャストができませんでした。

差分を見ると

import (
        context "context"
        fmt "fmt"
-       proto "github.com/golang/protobuf/proto"
+       proto "github.com/gogo/protobuf/proto"
        grpc "google.golang.org/grpc"

でした。
proto "github.com/golang/protobuf/proto"を使っていないとダメなようです。

なのでWithDetails()に入れるMessageに関しては公式のpluginを使ってコンパイルしてください。

サンプルコード

今回のサンプルコードはこちら

github.com

ソース