社内共通gRPCサービスにおけるエラーフォーマットの整備

ogp

はじめに

こんにちは、ソフトウェアエンジニアの大山です。

enechainでは、複数プロダクトから共通で利用される機能はマイクロサービスとして独立させており、それぞれがgRPC APIを提供しています。

Protocol Buffersの一元管理 ではそれらAPIの定義を一元管理する仕組みについて紹介しました。

共通系のサービスが自由なフォーマットで実装されていくとクライアント側の認知負荷が高まるため、正常系の処理だけでなく異常系の処理に関しても詳細なフォーマットが定められている事が望ましいです。 本記事では、今回新たに管理対象に追加したエラー情報の概要と、それらの記述をサポートするためのチェックツールについて紹介します。

背景

まず、API定義を管理するリポジトリは以下のような構成になっています。

├── doc
│   ├── service1
│   └── service2
├── gen
│   ├── go
│   │   ├── service1
│   │   └── service2
│   └── npm
│       ├── service1
│       └── service2
└── services
    ├── service1
    │   └── service1
    │       └── v1
    │           └── service.proto
    └── service2

各サービスは1つ以上のprotoファイルを持っており、開発者はprotoファイルを編集することでAPIの定義を変更します。 コードやドキュメント生成、パッケージ化はGitHub Actionsに準備されたワークフロー経由で自動的に行われます。 詳しくは 過去の記事 をご覧ください。

# protoファイルのサンプル
service SampleUserService {
  rpc ListEmails(ListEmailsRequest) returns (ListEmailsResponse) {}
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
}

message GetUserRequest {
  string user_id = 1;
}

message GetUserResponse {
  User user = 1;
}

message ListEmailsRequest {}

message ListBillingEmailsResponse {
  repeated User users = 1; 
}
...

管理を一元化することで共通サービスの利用負荷が軽減され、見通しもよくなりました。 ただ、protoファイルの仕様上エラー情報を持たないため、 以下のような課題がありました。

  • gRPCではエラーコードはアプリケーション固有のエラー表現ができないため、エラー種別ごとのハンドリングは行わず、RPCの文脈に基づく汎用的なエラー処理をしていた
  • エラー情報を共通サービスからクライアント側に伝える手段を持たないため、APIの変更に伴うエラーコードの更新を慎重にする必要があり、実装コストが高くなっていた

これらの課題を解決するため、エラー情報を適切に管理できる仕組みが求められていました。

エラー情報の管理方法

エラー情報を管理するにあたっては、担当チームでいくつかの案を検討しました。ここではそれらの概要、選定された方法とその理由を紹介します。 検討にあたっては他社事例1も参考にさせて頂きました。

案1: gRPCメソッドのresponseにエラー用のフィールドを追加する

gRPCメソッドのレスポンスにエラー用のフィールドを含めるようにします。 grpc-statusは常にOKを返し、エラー時には以下のようなレスポンスに定義されたエラー情報を返します。

# proto
message MyResponse {
  string result = 1;
  ErrorInfo error = 2; // nullでなければエラー
}
# 任意で定義できる
# 汎用的なものを共通で提供する想定
message ErrorInfo {
  string code = 1;
  string message = 2;
}

# client
# ハンドリングはresposeの他のフィールドと同じようになる
...
    if res.Error != nil {
        fmt.Printf("Code: %s\n", res.Error.Code)
        fmt.Printf("Message: %s\n", res.Error.Message)
        return
    }
---
Code: testerror
Message: something happened

Pros

  • 柔軟に独自エラーを設計/定義できる
  • proto上にエラー情報を定義するため見通しが良い
  • 正常系のハンドリングと同じような実装になるため、gRPC固有の知識が不要

    Cons

  • grpc-statusを使用しなくなる
    • status.Code によるエラーハンドリングが使用できない
  • 既存のエラーハンドリング実装を使用できなくなるため移行コストが高い
  • すべてのレスポンスのフィールドにエラーフィールドを追加する必要がある

案2: metadataにエラーを追加する

定義したエラーをgRPCメソッドのレスポンスではなく任意のフィールドで metadata に追加します。

# server
# metadata (任意だがここでは `x-app-error-bin` に追加)
...
appErr := &hellopb.AppError{
    Code:    "EMPTY_NAME",
    Message: "名前が空です",
}
bin, _ := proto.Marshal(appErr)
trailer := metadata.Pairs("x-app-error-bin", string(bin))
grpc.SetTrailer(ctx, trailer)
return &hellopb.HelloResponse{Message: ""}, nil
...

# client
if vals := trailer.Get("x-app-error-bin"); len(vals) > 0 {
    var appErr hellopb.AppError
    if err := proto.Unmarshal([]byte(vals[0]), &appErr); err == nil {
        fmt.Printf("アプリケーションエラー: Code=%s, Message=%s\n", appErr.Code, appErr.Message)
        return
    }
}

Pros

  • 柔軟に独自エラーを設計/定義できる
  • proto上にエラー情報を定義するため見通しが良い
  • metadataに追加するため、レスポンスのフィールドを変更する必要がない

Cons

  • grpc-statusを使用しなくなる
    • status.Code によるエラーハンドリングが使用できない
  • 既存のエラーハンドリング実装を使用できなくなるため移行コストが高い

案3: protoにコメント形式でエラー情報を追加

protoのコメントとしてエラー情報を書き込む方法です。 エラーについては公式で定義されている Richer error model を利用します。

# proto
service SampleUserService {
  // メールアドレスを取得する
  //
  // Possible gRPC errors
  // - INTERNAL: e.g., メールアドレスの取得に失敗した
  rpc ListEmails(ListEmailsRequest) returns (ListEmailsResponse) {}
  
  // ユーザーを取得する
  //
  // Possible gRPC errors
  // - INVALID_ARGUMENT: e.g., 不正なIDが指定された
  // - NOT_FOUND: e.g., ユーザーが存在しない
  // - INTERNAL: e.g., ユーザーの取得に失敗した
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}

Pros

  • 公式から提供されているパッケージで共通のエラーフォーマットを強制できる
  • protoからハンドリングが必要なエラーを把握できる

Cons

  • Richer error modelをサポートしていないライブラリを使用する場合は処理を実装する必要がある
  • protoに実装依存のコメントを残す必要がある
    • コメントを書くコストがかかる
    • 実装とコメントのずれやコメント追加が漏れる可能性がある

選定された方法

最終的に、enechainの共通サービスの規模感や移行コストから、案3のコメント形式でエラー情報を追加する案が選ばれました。

ただし、この方法ではコメントの記述ミスや追加忘れが発生する可能性があるため、それらを防ぐためにチェックツールを準備して自動的にエラー情報の整合性を確認することにしました。

チェックツールについて

チェックツールは以下の機能を持ち、リポジトリのGitHub Actionsで動作します。

  • 設定ファイルに定義されたエラー情報とprotoファイルのコメントから抽出したエラー情報を元に整合性を確認する
  • エラー情報はステータスコードとそれぞれのdetailsフィールドをサポートする
  • 設定はデフォルト、ディレクトリレベルで定義でき、より狭いスコープの設定を優先する

このツールでは、設定ファイルに従って以下のようなメソッドを検出できます。

  • n種類以上のステータスコードが定義されていないメソッド
  • 必須ステータスコード定義されていないメソッド
  • ステータスコードごとの必須detailsフィールドが定義されていないメソッド

一方で、 ステータスコードは定義されているがそれに対する使用用途などの説明がないメソッドや、 API実装とコメントのエラーに齟齬があるメソッドは現時点では検出できません。

前者については、ステータスコードからハンドリングに必要な情報は得ることができるため必要性は低いと判断しました。 後者については、対応するために設定ファイルではなくAPIの実装を正としてprotoと整合性を取る、もしくはprotoから実装側を生成することも検討しましたが、 工数や既存の開発への影響から設定ファイルを使用する方式に落ち着きました。

また、enechainでは既にAPI実装を持つリポジトリとprotoファイルを一元管理するリポジトリを分けて適切に運用できており、そこに対する整合性の齟齬は実用上問題になっていなかったことも理由の1つです。

以下は実際に使用しているデフォルトの全体設定です。

parser:
  supportedErrorTypes:
    - CANCELLED # リクエストがクライアントによってキャンセルされた
    - UNKNOWN # 不明なエラー
    - INVALID_ARGUMENT # クライアントが無効な引数を指定した
    - DEADLINE_EXCEEDED # リクエストの期限が切れた
    - NOT_FOUND # 指定されたリソースが見つからない
    - ALREADY_EXISTS # 作成しようとしたリソースが既に存在する
    - PERMISSION_DENIED # クライアントに操作の権限がない
    - RESOURCE_EXHAUSTED # リソース(割り当てなど)が不足している
    - FAILED_PRECONDITION # リクエストを実行する前にシステムが特定の状態であることを前提としているが、その状態でない
    - ABORTED # 操作が中止された(通常は同時実行の問題など)
    - OUT_OF_RANGE # 指定された範囲が無効
    - UNIMPLEMENTED # サーバーがリクエストされた操作を実装していない
    - INTERNAL # 内部サーバーエラー
    - UNAVAILABLE # サービスが一時的に利用できない
    - DATA_LOSS # 回復不能なデータの損失または破損
    - UNAUTHENTICATED # リクエストに有効な認証情報がない
  supportedErrorTypeDetails:
    - ERROR_INFO
    - RETRY_INFO
    - DEBUG_INFO
    - QUOTA_FAILURE
    - PRECONDITION_FAILURE
    - BAD_REQUEST
    - REQUEST_INFO
    - RESOURCE_INFO
# TODO: enable default requiredErrorTypes in the future
#requiredErrorTypes:
#  allOf:
#    - type: INVALID_ARGUMENT
#      allOf:
#        - ERROR_INFO
#      anyOf:
#        - BAD_REQUEST
#  anyOf:
#    - type: UNAUTHENTICATED
#      anyOf:
#        - ERROR_INFO
#        - DEBUG_INFO
#    - type: UNAVAILABLE
minErrorCount: 1 # 最低限必要なエラー数
failureMode: warning  # チェック失敗時の振る舞い: warningの場合はログを出力してコード0で終了する

supportedErrorTypessupportedErrorTypeDetails がprotoファイルからの抽出をサポートしているエラー種別です。 detailsフィールドはかなり多くの種類が定義されているため、使い勝手を考慮して必要なものだけをサポートするようにしています。

requiredErrorTypes に書かれたエラーがチェック対象になり、protoの内容が一致しない場合はチェックに失敗します。 enechainでは導入過渡期ということもありデフォルトでは requiredErrorTypes の設定は無効にしており、必要に応じて各サービスで設定を追加していく形をとっています。

以下はサービスでの設定例です。 requiredErrorTypes 以下にはすべて満たす必要がある allOf と、いずれかを満たせばよい anyOf があり、 それらをステータスコードとdetailsフィールドそれぞれで設定できます。

requiredErrorTypes:
  # 必ずINTERNALを含む
  # INTERNALのdetailsとして必ずERROR_INFOを含む
  # INTERNALのdetailsとしてBAD_REQUEST、PRECONDITION_FAILURE、QUOTA_FAILUREを1つ以上含む
  #   同階層のallOfとはAND条件(INTERNALはERROR_INFOを必ず含む必要がある)
  allOf:
    - type: INTERNAL
      allOf:
        - ERROR_INFO
      anyOf:
        - BAD_REQUEST
        - PRECONDITION_FAILURE
        - QUOTA_FAILURE

  # UNAUTHENTICATED、UNAVAILABLE、INTERNALのいずれかを含む
  #   同階層のallOfとはAND条件(INTERNALは必ず含む必要がある)
  # UNAUTHENTICATEDのdetailsとしてERROR_INFOまたはDEBUG_INFOを含む
  anyOf:
    - type: UNAUTHENTICATED
      anyOf:
        - ERROR_INFO
        - DEBUG_INFO
    - type: UNAVAILABLE
minErrorCount: 1 # 最低限必要なエラー数

実行結果は以下のように出力されます。

検査結果に加えて、チェックされたファイルとそれに使用された設定ファイル、protoから抽出されたエラー情報が出力されます。 検査に落ちた場合、開発者はこの結果をもとにprotoファイルを修正していく流れになります。

service TestService {

  // ListSample
  //
  // Possible gRPC errors
  // - INTERNAL
  //   - ERROR_INFO
  //   - BAD_REQUEST
  // - UNAVAILABLE
  rpc ListSample(ListSampleRequest) returns (ListSampleResponse) {}

  // 一覧取得
  //
  // INTERNAL
  //   ERROR_INFO
  //   PRECONDITION_FAILURE
  //   BAD_REQUEST
  // UNAUTHENTICATED
  //   ERROR_INFO
  rpc ListSample2(ListSample2Request) returns (ListSample2Response) {}

  // - INTERNAL
  //   ERROR_INFO
  //   QUOTA_FAILURE
  // - UNAVAILABLE
  rpc ListSample3(ListSample3Request) returns (ListSample3Response) {}
}


# execute check_rpc_error_comments
check_rpc_error_comments % go run ./ ../../services/testservice
Checking .proto files in ../../services/testservice for missing RPC error comments...
Checking: ../../services/testservice/testservice/v1/service.proto
Use ../../testservice/rpc-error-config.yaml
Required conditions: allOf: [map[allOf:[ERROR_INFO] anyOf:[BAD_REQUEST PRECONDITION_FAILURE QUOTA_FAILURE] type:INTERNAL]], anyOf: [map[anyOf:[ERROR_INFO DEBUG_INFO] type:UNAUTHENTICATED] map[type:UNAVAILABLE]]
RPC Methods (3):
  [0] ListSample
    ResponseName: ListSampleResponse
    Line: 14
    Error Types (2):
      [0] INTERNAL
        Details (2):
          [0] ERROR_INFO
          [1] BAD_REQUEST
      [1] UNAVAILABLE
type=UNAUTHENTICATED not found
  [1] ListSample2
    ResponseName: ListSample2Response
    Line: 24
    Error Types (2):
      [0] INTERNAL
        Details (3):
          [0] ERROR_INFO
          [1] PRECONDITION_FAILURE
          [2] BAD_REQUEST
      [1] UNAUTHENTICATED
        Details (1):
          [0] ERROR_INFO
  [2] ListSample3
    ResponseName: ListSample3Response
    Line: 30
    Error Types (2):
      [0] INTERNAL
        Details (2):
          [0] ERROR_INFO
          [1] QUOTA_FAILURE
      [1] UNAVAILABLE
type=UNAUTHENTICATED not found
All RPC response messages have proper error comments.


# fail sample
check_rpc_error_comments % go run ./ ../../services/testservice
...
Found 1 RPC response messages without proper error comments:

File: ../../services/testservice/testservice/v1/service.proto
  - RPC: ListSample2, Response: ListSample2Response, Line: 13
    - 最小エラー数(2)を満たしていません。現在: 1
    - エラータイプの条件を満たしていません。

まとめ

本記事では、enechainにおける共通gRPCサービスでのエラー情報の管理方法とそれをサポートするチェックツールを紹介しました。

enechainの規模や要件に合わせて、現状最適な仕組みを準備できたかなと思っています。

この仕組みを利用することで、定義ファイルからエラー情報を把握できるようになり、クライアント実装のコストやAPIのエラー追加など変更のハードルが下がることを期待しています。

今後も運用を通じてフィードバックを得ながら、より使いやすい形へと改善を続けていく予定です。


enechainでは、一緒に事業を拡大していける仲間を絶賛募集中です。

herp.careers