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 においているので、動かして試してみたい方はこちらも参照ください。

参考リンク