Go gRPC-Gatewayでカスタムエラーを返す方法


VISITS Technologies Advent Calendar 2019 23日目は@istshが担当します。
この記事はサンプルコードを使って解説してきます。

エラーをカスタマイズしたい

おそらくgRPCのエラー実装において、

status.New(codes.Unauthenticated, "not authenticated").Err()

のように書いたことがあると思います。
しかし、これではnot authenticatedというエラーメッセージしか扱えません。

私がいるプロジェクトでは、下記の3点を返す必要がありました。

  • エラーコード(StatusCodeではない)
  • ロケール
  • エラーメッセージ

この記事では、これらをgRPCで返し、さらにgRPC-Gateway(HTTP)で返すところまでの実装を紹介します。

LocalizedMessage

go get -u google.golang.org/genproto/googleapis/rpc/errdetails
// Provides a localized error message that is safe to return to the user
// which can be attached to an RPC error.
type LocalizedMessage struct {
    // The locale used following the specification defined at
    // http://www.rfc-editor.org/rfc/bcp/bcp47.txt.
    // Examples are: "en-US", "fr-CH", "es-MX"
    Locale string `protobuf:"bytes,1,opt,name=locale,proto3" json:"locale,omitempty"`
    // The localized error message in the above locale.
    Message              string   `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

error_details.pb.go#L670
このLocalizedMessageを使うことで、ロケールエラーメッセージを扱えそうです。

WithDetails

status.New(codes.Unauthenticated, "not authenticated").Err()で上記のLocalizedMessageを扱うには、下記のようにWithDetailsというメソッドを使います。

status.New(status.New(codes.Unauthenticated, "not authenticated").WithDetails(
    &errdetails.LocalizedMessage{
        Locale:  LocaleJaJp,
        Message: "ユーザーの認証ができませんでした。",
    },
))

ちなみにWithDetailsは、可変長引数を取ります。

func (s *Status) WithDetails(details ...proto.Message) (*Status, error) {
    // 省略...
}

よって、下記のようにLocalizedMessageを複数渡すことが可能です、

status.New(status.New(codes.Unauthenticated, "not authenticated").WithDetails(
    &errdetails.LocalizedMessage{
        Locale:  LocaleJaJp,
        Message: "ユーザーの認証ができませんでした。",
    },
    &errdetails.LocalizedMessage{
        Locale:  LocaleEnUs,
        Message: "Unauthenticated",
    },
))

これで、あるエラーにおいて日本語や英語のエラーメッセージを扱えることがわかりました。

エラーコード

エラーコードを扱うには、自前で用意する必要があるようです。

app/pb/v1/error.pb.go
type ErrorCode struct {
    ErrorCode            string   `protobuf:"bytes,1,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

func (m *ErrorCode) Reset()         { *m = ErrorCode{} }
func (m *ErrorCode) String() string { return proto.CompactTextString(m) }
func (*ErrorCode) ProtoMessage()    {}

これはerror.protoから生成したコードです。
WithDetailsは、下記のproto.Messageのインターフェースを実装した構造体であれば渡せるので、自動生成しなくても問題ありません。

// Message is implemented by generated protocol buffer messages.
type Message interface {
    Reset()
    String() string
    ProtoMessage()
}

カスタムエラーの定義

ここまでのコードをまとめると、下記のようになります。

app/status/status.go
status.New(codes.Unauthenticated, "not authenticated").WithDetails(
    &pbv1.ErrorCode{
        ErrorCode: "USER_UNAUTHENTICATED",
    },
    &errdetails.LocalizedMessage{
        Locale:  LocaleJaJp,
        Message: "ユーザーの認証ができませんでした。",
    },
    &errdetails.LocalizedMessage{
        Locale:  LocaleEnUs,
        Message: "Unauthenticated",
    },
)

カスタムエラーをgRPC-Gateway(HTTP)で返す

カスタムHTTPエラーの実装

app/cmd/client/http_error.go
func HTTPError(_ context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
    s, ok := status.FromError(err)
    if !ok {
        s = status.New(codes.Unknown, err.Error())
    }

    // 省略...

    ed := &pbv1.Error_ErrorDetail{}
    if len(s.Details()) > 0 {
        for _, detail := range s.Details() {
            switch v := detail.(type) {
            case *pbv1.ErrorCode:
                ed.ErrorCode = v.GetErrorCode()
            case *errdetails.LocalizedMessage:
                if ed.GetMessage() != "" {
                    // Already set error message.
                    continue
                }
                if v.GetLocale() == appstatus.LocaleJaJp {
                    ed.Locale = v.GetLocale()
                    ed.Message = v.GetMessage()
                }
            }
        }
    } else {
        ed.Message = s.Message()
    }
    e := pbv1.Error{
        Error: ed,
    }

    // 省略...
}

status.FromError(err)からgrpc.Statusを取得でき、DetailsメソッドでWithDetailsに渡したproto.Messageを取得できます。

gRPC-Gateway

gRPC-GatewayでHTTPエラーを扱う関数を変更する必要があります。

app/cmd/client/main.go
runtime.HTTPError = HTTPError

まとめ

【前編】【後編】に続き、gRPC-Gatewayに関する実装で、カスタムエラーを扱う方法を紹介しました。
WithDetailsメソッドを使えば、紹介したフィールド以外でも返せるので、より詳細なエラーをクライアントに伝えることが可能になります。

また、カスタムエラーの記事はいくつかありますが、metadataでを使う方法が多く、おそらく古い方法なのでお勧めしません。
もし、他にいい実装方法を知っている方がいたら、コメント等でご紹介いただければ幸いです。