LoginSignup
51
34

More than 3 years have passed since last update.

GRPCのリクエスト構造とエラーハンドリング

Last updated at Posted at 2020-12-20

この記事について

GRPC は HTTP/2 の上に構築されているため、クライアントからサーバーのサービス呼び出しや、サーバーからクライアントへ戻り値やエラーの送信、といったやり取りは HTTP/2 のリクエストとレスポンスで実装されています(もうちょっと厳密に言うと HTTP/2 フレームを使って実装されている)。

この記事ではGo言語のGRPC実装(grpc-go)を用いて、以下についてまとめてみました。

  • GRPC が用いる HTTP/2 のリクエスト、レスポンスの流れと構造
  • その中でエラーがどう伝播されるか
  • GRPC の Unary RPC のエラーハンドリング
  • 扱いにちょっと悩む Streaming RPC のエラーハンドリング

環境等の情報

  • 記事の作成に用いた環境:
    • Go言語 : v1.15.6
    • grpc-go : v1.34.0
  • この記事で用いたソースコード

GRPC の HTTP/2 リクエスト, レスポンスの流れと構造

まず、GRPC のサービス/メソッド呼び出し時の HTTP/2 フレームの流れや構造をざっとまとめます。詳細な仕様は PROTOCOL-HTTP2.md に記載がありますので必要に応じて参照してください。

なお、protobuf は以下のような非常にシンプルな定義を用いています。

greeter.proto
syntax = "proto3";

package greeter;
option go_package = ".;greeter";

service Greeter {
  // Unary RPC
  rpc SayHelloUN (HelloRequest) returns (HelloResponse) {}
  // Server streaming RPC
  rpc SayHelloSS (HelloRequest) returns (stream HelloResponse) {}
  // Client streaming RPC
  rpc SayHelloCS (stream HelloRequest) returns (HelloResponse) {}
  // Bi-directional streaming RPC
  rpc SayHelloBI (stream HelloRequest) returns (stream HelloResponse) {}
}

message HelloRequest {
  string name = 1;
}
message HelloResponse {
  string result = 1;
}

Unary RPC の場合

クライアントからのメソッド呼び出し

クライアントから呼び出す GRPC のサービス名/メソッド名は HEADERS フレームで送られ、次にメソッドに渡す引数は DATA フレームで送られます。
http2_flow_request_unary_rpc.png

HEADERS フレームの中身
主なヘッダーだけ以下に抜粋します。他にもメタデータやタイムアウト値なども送ることが出来ます。

名前 説明 値(例)
:method GRPC では POST のみ使う POST
:path /<サービス名>/<メソッド名> というパス /greeter.Greeter/SayHelloUN
:scheme https または http https
content-type 通常は application/grpc application/grpc

[パケットキャプチャの例]
http2_request_unary_rpc_packet_header.png

DATA フレームの中身
DATAフレームのペイロードには、GRPCのヘッダーと protobuf でエンコードされたメソッドの引数が入ります。なお、DATAフレームには EOS(End of stream)を表すために END STREAM フラグがセットされます。

[パケットキャプチャの例]
http2_unary_data_frame_pcap.png

サーバーからの戻り値の返却

サーバーからは HEADERS フレームで呼び出し結果が、DATAフレームで戻り値が返るのですが、ちょっと面白いのは HTTP の Status コードは常に 200 が返り、GRPC の呼び出し結果(成功/失敗、エラーメッセージ等)はそれとは別に返されることです。このGRPCの呼び出し結果を含むヘッダーを Trailers と呼びます。
http2_unary_rpc_response.PNG

最初の HEADERS フレームの中身
上記の通り、HTTP Status コードである :status は GRPC メソッドの成否によらず200で固定です(ただ推測ですが、GRPCメソッド呼び出しに至らないトランスポートレベルのエラーではそれ以外の値が返るのかもしれません)。他にはメタデータなどを含められます。

[パケットキャプチャの例]
http2_unary_response_header_frame_pcap.PNG

Trailers の HEADERS フレームの中身
こっちが GRPC の呼び出し結果を含みます。エラーも Trailers で送信されますが、詳細は後述します。以下のヘッダが必須です。

名前 説明 値(例)
grpc-status GPRCの呼び出し結果を表すコード 0
grpc-message エラー発生時のメッセージ

[パケットキャプチャの例]
http2_unary_response_header_trailers_frame_pcap.PNG

Data フレームの中身
メソッド呼び出しの場合と同じ構造なので割愛します。

Server Streaming RPC の場合

クライアントからのメソッド呼び出し

Unary RPC の場合と同じです。

サーバーからのデータ送信

クライアントからの呼び出し、つまり、1つのHTTP/2リクエストに対してまず HEADERS フレームを返し、その後はサーバー側でGRPCストリームの Send()/SendMsg() を呼ぶ度に、その引数が DATA フレームで送信されます。DATA フレーム自体の構造は Unary RPC と同じです。

http2_sever_streaming_rpc_response.PNG

Client Streaming RPC の場合

クライアントからのメソッド呼び出し

クライアント側で GRPC ストリームの Send()/SendMsg() を呼ぶたびにその引数が DATA フレームでサーバーへ送信されます。HEADERS フレームは初回の Send()/SendMsg() 呼び出し時に送信されるようです。
http2_flow_request_streaming_rpc.png

サーバーからの戻り値の返却

Unary の場合と同様に HEADERS フレームで HTTP Status、DATA フレームで戻り値を返すことができます。(自分が試した範囲では Trailers は返されませんでした)。

Bi-Directional Streaming RPC の場合

Server Streaming と Client Streaming を合わせた形になるので割愛します。

GRPC のエラーはどう伝播されるか?

GRPC のメソッド呼び出しでエラーが発生すると、エラーは先に見た Trailers として伝播されます。なお、エラー発生時は通常は返すべき DATA フレームが無いので、HTTP Status を含む HEADERS フレームに Trailers の情報も含めて1回で送られます。

error_simple.PNG

また、詳細なエラー情報を含めることができ、その場合は以下のようになります。
error_details.PNG

では、ここに出てきた grpc-status, grpc-message, grpc-status-details-bin や詳細なエラー情報とは何者かを次に見てみます。

GRPC のエラーハンドリングの設計

これらは GRPC のエラーハンドリングの設計から来ています。設計は Error handlingで説明されていますが、GRPC は Google が開発したことからそのエラーハンドリングも Google API の思想を受け継いでいます。ざっくりまとめるとこんな感じです。

  • Standard error modelRicher error model という2つのモデルある
  • Standard error model
    • GRPCのステータスコードとメッセージを返すだけのシンプルなもの
    • 全ての GRPC ライブラリで使用可能
  • Richer error model
    • Google API で使われているエラーモデルを用いる
      • Status という型でエラーを扱う
    • Status ではGRPCのステータスコードとメッセージに加えて、より詳細なエラー情報を返せる
      • GRPCのステータスコードはcode.protoで定義されているものを用いる
      • よく使われる詳細なエラー型は error_details.proto に定義されている
    • ただし、使える言語は限られている(C++, Go, Java, Python, and Ruby)

先に出た grpc-status ヘッダーは上記のGRPCのステータスコード、grpc-message はエラーメッセージを表します。また、詳細なエラー情報として error_details.proto または、アプリ独自のカスタムエラーを渡すことができ、protobuf で marshal して base64 エンコードしてから grpc-status-details-bin ヘッダーに格納されます。

Go 言語におけるエラー周りの実装

Google APIのエラーモデルにあるように、GRPC ではエラーを Status という型で扱います。

Go言語では、Status は google.golang.org/grpc/status パッケージをインポートすれば使えます。なお、実体は以下のファイルで定義されてます。
https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/status#Status

type Status struct {
    // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
    Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
    // A developer-facing error message, which should be in English. Any
    // user-facing error message should be localized and sent in the
    // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
    Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
    // A list of messages that carry the error details.  There is a common set of
    // message types for APIs to use.
    Details []*anypb.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"` // contains filtered or unexported fields

}

また、ステータスコードは google.golang.org/grpc/codes パッケージで定義されてます。

const (
    OK Code = 0
    Canceled Code = 1
    Unknown Code = 2
    InvalidArgument Code = 3
    // 中略...
    DataLoss Code = 15
    Unauthenticated Code = 16
)

Status に詳細なエラーを含めたい場合は WithDetailsというメソッドを用います。

func (s *Status) WithDetails(details ...proto.Message) (*Status, error)

以下、事前定義されているエラーコード、型などが定義されたURLをまとめておきます。

Unary RPC でのエラーハンドリング

では、エラーハンドリングを行うためのコードを見てみます。

Unary RPC ではサーバー側のメソッド内でエラーを検知した場合、戻り値としてエラーを返します。すると、Trailers によりクライアントにエラー情報が送信されます。

以下は詳細なエラー情報が無い場合の例です。単純に google.golang.org/grpc/status パッケージの status.Errorfstatus.New などの関数で Status を生成して返します。

サーバー側
import (
    pb "kitauji/greeter"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    // 他は記載省略
)

func (s *myServer) SayHelloUN(ctx context.Context, req *pb.HelloRequest) (*pb.HelloResponse, error) {
    if req.GetName() == "" {
        return nil, status.Errorf(codes.InvalidArgument, "Name is blank")
    }   
    // 以下略

次は、詳細なエラー情報を含める場合の例です。errdetails で定義されている BadRequest 型のエラーを生成して、WithDetails で Status に埋め込んでいます。

サーバー側
import (
    pb "kitauji/greeter"
    "google.golang.org/genproto/googleapis/rpc/errdetails"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

func (s *myServer) SayHelloUN(ctx context.Context, req *pb.HelloRequest) (*pb.HelloResponse, error) {
    if req.GetName() == "" {
        st := status.New(codes.InvalidArgument, "Name is blank")
        details := &errdetails.BadRequest{
            FieldViolations: []*errdetails.BadRequest_FieldViolation{
                {Field: "Name", Description: "Name is required. Only alphanumeric characters are allowed"},
            },
        }
        st, _ = st.WithDetails(details)
        return nil, st.Err()
    }
    // 以下略

クライアント側は、次のようなコードで送られてきた Status を取得することができます。

クライアント側
    // Unary RPC の呼び出し
    resp, err := cli.SayHelloUN(context.Background(), req)
    if err != nil {
        // error から Status への変換
        st, ok := status.FromError(err)
        if !ok {
            // エラー処理...
            return
        }

        log.Printf("GRPC Error : Code [%d], Message [%s]", st.Code(), st.Message())

        // 詳細なエラー情報があれば処理する
        if len(st.Details()) > 0 {
            for _, detail := range st.Details() {
                // errdetails の型により処理を分ける
                switch d := detail.(type) {
                case *errdetails.BadRequest:
                    log.Printf("Details: BadRequest: %v", d)
                    // エラー処理...
                case *errdetails.DebugInfo:
                    log.Printf("Details: DebugInfo: %v", d)
                    // エラー処理...
                default:
                    log.Printf("Details: Unknown: %v", d)
                }
            }
        }
    }

Streaming RPC のエラーハンドリング

Unary RPC の場合は、サーバー側は単に自身のメソッドの戻り値で Status を返してあげれば良かったので簡単なのですが、Streaming RPC の場合はそれをしてしまうと GRPC の接続が切れてしまうのでどう対処するか考えどころです。

以下は Bi-Directional Streaming RPC のサーバー側実装例で、クライアントから送られたデータに問題があった際(引数が空文字など)にエラーを返したいが、GRPCの接続は維持したい場合です。

func (s *myServer) SayHelloBI(stream pb.Greeter_SayHelloBIServer) error {
    for {
        req, err := stream.Recv()
        if err != nil {
            log.Printf("SayHelloBI: Recv() error : %v", err)
            return err
        }

        if req.GetName() == "" {
            // Name が空の場合はクライアントにエラーを通知したい。
            // ただし、このように単純にエラーを return すると接続が切れてしまう
            return status.Errorf(codes.InvalidArgument, "Name is blank")
        }

        // 以下、何かしら処理が続く
    }
}

そもそもですが受信したデータに問題があった都度エラーを返すとしても、Streaming は結局1つの HTTP/2 リクエスト/レスポンスの上でやり取りされてるので、その中で grpc-statusgrpc-message ヘッダーを複数回返せないはずです。

では、どうやってクライアントへエラーを返すにはどうすればよいか?
いくつかやり方はありそうですが、Streaming のメッセージとして返す方法について書いてみます。

まず *.proto ファイルの定義が以下のようになっているとサーバーからは HelloResponse しか返せないのでこれを拡張して Status を含められるようにします。

service Greeter {
  // ...
  // Bidirectional streaming RPC
  rpc SayHelloBI (stream HelloRequest) returns (stream HelloResponse) {}
}

message HelloRequest {
  string name = 1;
}
message HelloResponse {
  string result = 1;
}

Status の持ち方はフラットに持つか oneof を使うかなどいくつかの案がありそうですが、oneof の方が綺麗に行きそうなのでそれでやってみます。なお、oneof は C言語で言う union のようなもので、その中のフィールドの1つだけを使うことが出来ます(この場合は result か status のいずれかを使える)。

// フラットに持たせる
message StreamingHelloResponse {
  string result = 1;
  google.rpc.Status status = 2;
}
// oneof を使う
message StreamingHelloResponse {
  oneof response {
    string result = 1;
    google.rpc.Status status = 2;
  }
}

では、この方針で実装してみます。

実装の仕方

1. status.proto のダウンロード

最初に protobuf の Status 型が定義されている https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto をダウンロードします。

ダウンロードしたファイルは、protoc (Protobuf のコンパイラ)が配置されてるディレクトリの include 配下に置きました。なお、自分の *.proto ファイル(今回の greeter.proto) があるディレクトリの配下に google/rpc ディレクトリを掘ってその中に入れてもいいようです。

├── bin
│   └── protoc
├── include
│   └── google
│       ├── protobuf
│       │   ├── any.proto
│       │   ├── api.proto
│       │   └── などなど
│       └── rpc
│           └── status.proto  ← rpc ディレクトリを作ってここに置いた
└── readme.txt

2. *.proto ファイルの修正

自分の *.proto ファイルを以下のように修正して protoc でコンパイルします。ポイントはダウンロードした status.proto をインポートして google.rpc.Status型を使えるようにしていることです。

greeter.proto
// ダウンロードしたファイルをインポート
import "google/rpc/status.proto";

service Greeter {
  // レスポンスの型を HelloResponse から StreamingHelloResponse に変更
  rpc SayHelloBI (stream HelloRequest) returns (stream StreamingHelloResponse) {}
}

// 新しく追加する
message StreamingHelloResponse {
  oneof response {
    string result = 1;             // 成功時はこちらが返る
    google.rpc.Status status = 2;  // エラー時はこちらが返る  
  }
}

3. サーバー側のエラー処理
次にサーバー側でエラーを検知した際の返し方です。*.proto で定義した StreamingHelloResponse を生成しています。

なお、以下若干ややこしいところです。

  • status.New で返されるのは google.golang.org/grpc/status.Status
  • StreamingHelloResponse_Status 内の Status はgoogle.golang.org/genproto/googleapis/rpc/status.Status
  • 型が異なるが st.Proto で変換可能
サーバー側
func (s *myServer) SayHelloBI(stream pb.Greeter_SayHelloBIServer) error {
    for {
        req, err := stream.Recv()
        if err != nil {
            log.Printf("SayHelloBI: Recv() error : %v", err)
            return err
        }

        log.Printf("SayHelloBI: Received: %v", req)

        if req.GetName() == "" {
            // Name が空の場合はクライアントにエラーを通知したい。

            // まず通常の Status を生成。
            st := status.New(codes.InvalidArgument, "Name is blank")

            // *.proto の StreamingHelloResponse 内で定義した Status を生成
            respStatus := &pb.StreamingHelloResponse_Status{Status: st.Proto()}

            // StreamingHelloResponse を生成して、クライアントに送信する
            resp := &pb.StreamingHelloResponse{Response: respStatus}
            stream.Send(resp)
        }

4. クライアント側の処理

クライアントは受信したレスポンスが「成功時の戻り値」か「エラーの通知か」を Status の有無で判断できます。

クライアント側
func SayHelloBIRecv(stream pb.Greeter_SayHelloBIClient) {
    // 中略

    resp, err := stream.Recv()
    if err != nil {
        // 受信失敗時の処理
    }

    // 受信したレスポンスに Status 情報(つまりエラー)が含まれているかチェック
    if st := resp.GetStatus(); st != nil {
        // あれば中身の Status を取り出して処理する
        s := status.FromProto(st)
        log.Printf("Status : Code [%d], Message [%s]", s.Code(), s.Message())

    }

    // 成功時の戻り値が入っていることをチェック
    // (Status が空なので入っているはずだが念のため)
    if resp.GetResult() != "" {
        // 成功時の処理
    }

以上のような実装で Streaming の場合もエラーを通知することができます。

51
34
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
34