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

[VISITS Technologies Advent Calendar 2019](https://qiita.com/advent-calendar/2019/visits) 23日目は@istshが担当します。
この記事は[サンプルコード](https://github.com/istsh/go-grpc-sample)を使って解説してきます。

# エラーをカスタマイズしたい
おそらくgRPCのエラー実装において、

```go
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
```
```go
// 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](https://github.com/googleapis/go-genproto/blob/master/googleapis/rpc/errdetails/error_details.pb.go#L670)
この`LocalizedMessage`を使うことで、`ロケール`と`エラーメッセージ`を扱えそうです。

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

```go
status.New(status.New(codes.Unauthenticated, "not authenticated").WithDetails(
	&errdetails.LocalizedMessage{
		Locale:  LocaleJaJp,
		Message: "ユーザーの認証ができませんでした。",
	},
))
```
ちなみに`WithDetails`は、可変長引数を取ります。

```go
func (s *Status) WithDetails(details ...proto.Message) (*Status, error) {
	// 省略...
}
```
よって、下記のように`LocalizedMessage`を複数渡すことが可能です、

```go
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`のインターフェースを実装した構造体であれば渡せるので、自動生成しなくても問題ありません。

```go
// 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
```

# まとめ
[【前編】](https://qiita.com/istsh/items/030933950f9fe8c82844)[【後編】](https://qiita.com/istsh/items/7e5d624fe8504e21ded4)に続き、gRPC-Gatewayに関する実装で、カスタムエラーを扱う方法を紹介しました。
`WithDetails`メソッドを使えば、紹介したフィールド以外でも返せるので、より詳細なエラーをクライアントに伝えることが可能になります。

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