grpc-gatewayのエラーレスポンスをカスタマイズする
grpc-gateway は gRPC のエラーコードと HTTP のステータスコードをよしなに変換してくれますが、クライアントにエラーの理由を知らせる場合にはエラーレスポンスをカスタマイズする必要があります。
本記事では grpc-go + grpc-gateway でカスタムエラーレスポンスを返す方法について、実装例を出しながら紹介します。
いつカスタムエラーレスポンスが必要になるか
APIがエラーを返した理由をクライアントに見せる必要がある場合、エラーレスポンスをカスタマイズする必要があります。
例えば、登録済みのメールアドレス、リンクの有効期限切れ、操作手順の誤りをクライアントに伝えたい場合などです。
エラーレスポンスの設計
カスタムエラーレスポンスの標準的な形式がRFC7807(日本語訳) で定義されています。エラーの種類をuriで扱う仕様になっていますが、基本開発ではフロントとバックエンドで共通のエラーコードが定義されていれば十分だと考えているので不採用とします。
そのため、既存のAPIのエラーレスポンスを参考に決めて行きます。
ありがたいことに、下記記事でいくつかのwebサービスのエラーレスポンスを紹介してくれています。
WebAPIでエラーをどう表現すべき?15のサービスを調査してみた
上記記事で紹介されているエラーレスポンスのうちFacebookの定義が扱いやすそうなため、これをベースにして次のように定義します。
{
"error": {
"message": "error message",
"name": "ErrorName",
"code": 1000
}
}
エラーレスポンスをカスタマイズする
エラーの変換を行っている箇所
grpc-gatewayは grpc-gateway/runtime/errors.go の HTTPError でgRPCのエラーからHTTPのエラーレスポンスの構築を行っています。
HTTPErrorは 次のような関数を保持する、Exportされている変数です。所望のエラーレスポンスを返すHTTPError関数を実装していきます。
func DefaultHTTPError(ctx context.Context, mux *ServeMux, marshaler Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
...
}
Metadata, Header, Trailerとは
gRPC では、リクエスト、レスポンスパラメータ以外の情報を Metadata と呼び、key-value(s) 形式で付加することができます。
Metadata には2種類あり、エラーに関する情報を Trailer に、それ以外の情報を Header に格納します。 リクエスト時にエラー情報は不要なため、リクエスト側は Header のみを、レスポンス側は Header, Trailer 両方を付加することができます。
今回扱うのはエラー情報なので、gRPCサーバでエラーの詳細を Trailer に格納し、grpc-gatewayでその内容をエラーレスポンスに変換することでエラーの詳細を返すようにします。
エラーの変換を行う関数の実装
gRPCのエラーからHTTPのエラーレスポンスの構築を行う、HTTPError 関数を実装していきます。
デフォルトのHTTPError関数の実装を確認する
まず元の実装であるDefaultHTTPErrorを見てみます。
func DefaultHTTPError(ctx context.Context, mux *ServeMux, marshaler Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
const fallback = `{"error": "failed to marshal error message"}`
s, ok := status.FromError(err)
if !ok {
s = status.New(codes.Unknown, err.Error())
}
w.Header().Del("Trailer")
contentType := marshaler.ContentType()
// Check marshaler on run time in order to keep backwards compatability
// An interface param needs to be added to the ContentType() function on
// the Marshal interface to be able to remove this check
if httpBodyMarshaler, ok := marshaler.(*HTTPBodyMarshaler); ok {
pb := s.Proto()
contentType = httpBodyMarshaler.ContentTypeFromMessage(pb)
}
w.Header().Set("Content-Type", contentType)
body := &errorBody{
Error: s.Message(),
Message: s.Message(),
Code: int32(s.Code()),
Details: s.Proto().GetDetails(),
}
buf, merr := marshaler.Marshal(body)
if merr != nil {
grpclog.Infof("Failed to marshal error message %q: %v", body, merr)
w.WriteHeader(http.StatusInternalServerError)
if _, err := io.WriteString(w, fallback); err != nil {
grpclog.Infof("Failed to write response: %v", err)
}
return
}
md, ok := ServerMetadataFromContext(ctx)
if !ok {
grpclog.Infof("Failed to extract ServerMetadata from context")
}
handleForwardResponseServerMetadata(w, mux, md)
handleForwardResponseTrailerHeader(w, md)
st := HTTPStatusFromCode(s.Code())
w.WriteHeader(st)
if _, err := w.Write(buf); err != nil {
grpclog.Infof("Failed to write response: %v", err)
}
handleForwardResponseTrailer(w, md)
}
marshalling 部分や Metadata 取得部分はそのまま使えばいいので、あとエラー型を定義してエラー詳細を設定するロジックを追加すれば良さそうです。
handleForward* 関数は Metadata を変換してレスポンスの HTTP ヘッダに設定する関数ですが、一旦必要ないので削除します。
エラー型を定義する
次のように定義します。エラー詳細は複数ある可能性があるので slice とし、その中に エラーレスポンスの設計 で定義したエラーを詰めます。
type ErrorBody struct {
Message string `json:”message"`
GrpcCode int32 `json:"grpcCode"`
Details []ErrorDetail `json:"details"`
}
type ErrorDetail struct {
Code int32 `json:"code"`
Name string `json:"name"`
Message string `json:"message"`
}
CustomHTTPError 関数を実装する
Trailer からエラー情報を取得し、レスポンスボディに追加するよう修正した HTTPError 関数は下記コードのようになります。
runtime.ServerMetadata から Trailer の情報を取得し、Body にセットする処理を追加しています。
レスポンスエラーの詳細と他のエラーが区別できるよう、Trailer に書き込む際には決まった key (ここでは ErrorDetailKey) に対する value として書き込んでいます。
const ErrorDetailKey = "error-detail"
// trailerに格納されている情報のうち、ErrorDetailを取得する
func GetErrorDetails(md runtime.ServerMetadata) ([]ErrorDetail, error) {
details := make([]ErrorDetail, 0)
for k, vs := range md.TrailerMD {
if !strings.Contains(k, ErrorDetailKey) {
continue
}
for _, v := range vs {
var detail ErrorDetail
if err := json.Unmarshal([]byte(v), &detail); err != nil {
return nil, err
}
details = append(details, detail)
}
}
return details, nil
}
func CustomHTTPError(ctx context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
const fallback = `{"error": "failed to marshal error message"}`
w.Header().Del("Trailer")
w.Header().Set("Content-Type", marshaler.ContentType())
s, ok := status.FromError(err)
if !ok {
s = status.New(codes.Unknown, err.Error())
}
body := &ErrorBody{
Message: s.Message(),
GrpcCode: int32(s.Code()),
}
// set error details to body
md, ok := runtime.ServerMetadataFromContext(ctx)
if !ok {
grpclog.Infof("Failed to extract ServerMetadata from context")
}
details, err := GetErrorDetails(md)
if err != nil {
grpclog.Infof("Failed to get ErrorDetails from metadata")
}
body.Details = details
buf, merr := marshaler.Marshal(body)
if merr != nil {
grpclog.Infof("Failed to marshal error message %q: %v", body, merr)
w.WriteHeader(http.StatusInternalServerError)
if _, err := io.WriteString(w, fallback); err != nil {
grpclog.Infof("Failed to write response: %v", err)
}
return
}
// convert grpc code to http code
st := runtime.HTTPStatusFromCode(s.Code())
w.WriteHeader(st)
if _, err := w.Write(buf); err != nil {
grpclog.Infof("Failed to write response: %v", err)
}
}
以上でカスタムエラーレスポンスを返す関数を実装できました。
gRPCサーバでエラーを返してみる
試しにTrailerへエラー詳細を詰めて、エラーレスポンスを返す gRPCサーバを実装してみましょう。
下記コードが gRPC サーバの実装例です。リクエストパラメータに Success が無いまたは false ならエラーを返し、true なら適当なデータを返します。
// ErrorDetail の定義例
var (
SuccessIsFalse = ErrorDetail{Name: "ErrorDetail", Code: 2000, Message: "success param is false"}
)
// TrailerにErrorDetailを詰める関数
func AddErrorDetail(ctx context.Context, detail ErrorDetail) error {
bytes, err := json.Marshal(detail)
if err != nil {
return err
}
md := metadata.Pairs(ErrorDetailKey, string(bytes))
return grpc.SetTrailer(ctx, md)
}
// GetData APIの実装。success パラメータがfalseならレスポンスにErrorDetailを含めて返す
func (s Server) GetData(ctx context.Context, req *proto.GetDataRequest) (*proto.GetDataResponse, error) {
if !req.Success {
_ = errdetails.AddErrorDetail(ctx, SuccessIsFalse)
return &proto.GetDataResponse{}, status.Error(codes.InvalidArgument, "invalid request")
}
return &proto.GetDataResponse{
Str: "result",
Num: 5,
}, nil
}
success = falseでリクエストした場合のレスポンスは下記のようになります。
{
"Message": "invalid request",
"grpcCode": 3,
"details": [
{
"code": 2000,
"name": "ErrorDetail",
"message": "success param is false"
}
]
}
エラーとその原因をレスポンスに含めて返せるようになりました。
まとめ
本記事では grpc-go + grpc-gateway の環境でエラーレスポンスをカスタマイズする方法を紹介しました。
grpc-gateway の HTTPError 関数の処理を差し替えることでエラーレスポンスをカスタマイズすることができます。
本記事で紹介している実装は soichisumi-sandbox/grpc-custom-error-sample においているので、動かして試してみたい方はこちらも参照ください。