この記事について
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 は以下のような非常にシンプルな定義を用いています。
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 フレームで送られます。
HEADERS フレームの中身
主なヘッダーだけ以下に抜粋します。他にもメタデータやタイムアウト値なども送ることが出来ます。
名前 | 説明 | 値(例) |
---|---|---|
:method | GRPC では POST のみ使う | POST |
:path |
/<サービス名>/<メソッド名> というパス |
/greeter.Greeter/SayHelloUN |
:scheme | https または http | https |
content-type | 通常は application/grpc
|
application/grpc |
DATA フレームの中身
DATAフレームのペイロードには、GRPCのヘッダーと protobuf でエンコードされたメソッドの引数が入ります。なお、DATAフレームには EOS(End of stream)を表すために END STREAM
フラグがセットされます。
サーバーからの戻り値の返却
サーバーからは HEADERS フレームで呼び出し結果が、DATAフレームで戻り値が返るのですが、ちょっと面白いのは HTTP の Status コードは常に 200 が返り、GRPC の呼び出し結果(成功/失敗、エラーメッセージ等)はそれとは別に返されることです。このGRPCの呼び出し結果を含むヘッダーを Trailers と呼びます。
最初の HEADERS フレームの中身
上記の通り、HTTP Status コードである :status
は GRPC メソッドの成否によらず200で固定です(ただ推測ですが、GRPCメソッド呼び出しに至らないトランスポートレベルのエラーではそれ以外の値が返るのかもしれません)。他にはメタデータなどを含められます。
Trailers の HEADERS フレームの中身
こっちが GRPC の呼び出し結果を含みます。エラーも Trailers で送信されますが、詳細は後述します。以下のヘッダが必須です。
名前 | 説明 | 値(例) |
---|---|---|
grpc-status | GPRCの呼び出し結果を表すコード | 0 |
grpc-message | エラー発生時のメッセージ |
Data フレームの中身
メソッド呼び出しの場合と同じ構造なので割愛します。
Server Streaming RPC の場合
クライアントからのメソッド呼び出し
Unary RPC の場合と同じです。
サーバーからのデータ送信
クライアントからの呼び出し、つまり、1つのHTTP/2リクエストに対してまず HEADERS フレームを返し、その後はサーバー側でGRPCストリームの Send()/SendMsg() を呼ぶ度に、その引数が DATA フレームで送信されます。DATA フレーム自体の構造は Unary RPC と同じです。
Client Streaming RPC の場合
クライアントからのメソッド呼び出し
クライアント側で GRPC ストリームの Send()/SendMsg() を呼ぶたびにその引数が DATA フレームでサーバーへ送信されます。HEADERS フレームは初回の Send()/SendMsg() 呼び出し時に送信されるようです。
サーバーからの戻り値の返却
Unary の場合と同様に HEADERS フレームで HTTP Status、DATA フレームで戻り値を返すことができます。(自分が試した範囲では Trailers は返されませんでした)。
Bi-Directional Streaming RPC の場合
Server Streaming と Client Streaming を合わせた形になるので割愛します。
GRPC のエラーはどう伝播されるか?
GRPC のメソッド呼び出しでエラーが発生すると、エラーは先に見た Trailers として伝播されます。なお、エラー発生時は通常は返すべき DATA フレームが無いので、HTTP Status を含む HEADERS フレームに Trailers の情報も含めて1回で送られます。
また、詳細なエラー情報を含めることができ、その場合は以下のようになります。
では、ここに出てきた grpc-status
, grpc-message
, grpc-status-details-bin
や詳細なエラー情報とは何者かを次に見てみます。
GRPC のエラーハンドリングの設計
これらは GRPC のエラーハンドリングの設計から来ています。設計は Error handlingで説明されていますが、GRPC は Google が開発したことからそのエラーハンドリングも Google API の思想を受け継いでいます。ざっくりまとめるとこんな感じです。
-
Standard error model
とRicher 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)
- Google API で使われているエラーモデルを用いる
先に出た 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をまとめておきます。
- エラーを扱う Status 型
- Standard error model のステータスコード
- Protobuf版: https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto
- Go言語版: https://pkg.go.dev/google.golang.org/grpc/codes
- Richer error model の詳細なエラー型
Unary RPC でのエラーハンドリング
では、エラーハンドリングを行うためのコードを見てみます。
Unary RPC ではサーバー側のメソッド内でエラーを検知した場合、戻り値としてエラーを返します。すると、Trailers によりクライアントにエラー情報が送信されます。
以下は詳細なエラー情報が無い場合の例です。単純に google.golang.org/grpc/status パッケージの status.Errorf
や status.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-status
や grpc-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
型を使えるようにしていることです。
// ダウンロードしたファイルをインポート
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 の場合もエラーを通知することができます。