🟥

GraphQLにおけるエラーハンドリングの選択肢と検討

2023/10/25に公開

こんにちは!

ココナラテックエージェントFuturizm を始め、いくつかの新規事業開発を担当している大川です。

今回は、現在模索しているGraphQLのエラーハンドリングについて書いていこうと思います。
模索中ではあるものの、模索する上で調査した他社事例であったり、複数の選択肢の中から比較を行う観点等を掲載していますので、一部の方には刺さるのではないかと思い執筆することにしました。

背景

私たちのチームは、バックエンドでAPIを構築する際、Ruby on Railsを用いてGraphQL APIを構築する選択肢がファーストチョイスとなっています。

しかし、GraphQLのエラーハンドリングにはいくつかの選択肢があり、自分達に合う方法を見つけることが出来ていませんでした。

そこで、新しくGraphQL APIサーバーを構築することをきっかけに、改めて主流となるエラーハンドリングの方法を調査し運用方法を再定義したいと考えました。

エラーの定義

エラーハンドリングの方法を検討する上で、エラーの定義がざっくり以下の2つに分類できることを知っておく必要があるため軽く触れておきます。

  1. 業務エラー
  2. システムエラー

1. 業務エラー

業務エラーとは 「ユーザーが操作し直すことで復帰できるエラー」 のことです。システムから見たら、想定内のエラーとも言えます。

具体的には以下のようなエラーです。

  • 文字数制限を超えるタイトルを登録しようとしてエラーとなる
  • 正しくないフォーマットのメールアドレスを登録しようとしてエラーとなる

これらのエラーは、ユーザーに対してエラーの原因を通知することで、ユーザーの再操作により復帰することができます。

2. システムエラー

システムエラーは 「ユーザーが操作し直しても復帰できないエラー」 のことです。システムから見たら、想定外のエラーとも言えます。

具体的には以下のようなエラーです。

  • ソースコードにバグがあり、データを登録しようとしてもエラーとなる
  • サーバーがダウンしている

これらのエラーは、基本的にはユーザーが再操作をしても復帰することはありません。システム管理者がエラーの内容を検知し対応を行う必要があります。

なお、エラーの定義は以下の記事を参考にしています。

https://qiita.com/jnchito/items/3ef95ea144ed15df3637

GraphQLのエラー設計についても、上記2つのエラー分類を意識しながら行う必要があります。

GraphQL標準のエラーフォーマット

次にGraphQLの標準仕様で定義されているエラーフォーマットを見ていきます。

{
  "errors": [
    {
      // 開発者向けのエラー(システムエラー)メッセージ
      "message": "Name for character with ID 1002 could not be fetched.",
      // GraphQLクエリでエラーが発生した具体的な位置
      "locations": [{ "line": 6, "column": 7 }],
      // エラーが発生したデータのパス
      "path": ["hero", "heroFriends", 1, "name"],
      // 独自のエラー拡張情報
      "extensions": {
        "code": "CAN_NOT_FETCH_BY_ID",
        "timestamp": "Fri Feb 9 14:33:09 UTC 2018"
      }
    }
  ]
}

アプリケーション独自のエラー情報を付与する手段として、 extensions キーを利用することができます。値にはハッシュを設定することができ、内容に制限はありません。

https://spec.graphql.org/October2021/#sec-Errors

エラーフォーマットの3つの選択肢

調査を進めていく中で、業務エラーを通知する方法について様々な企業で模索されていることがわかりました。
方法は大まかに3つのパターンに分けられます。ここでは他社事例を交えて紹介していきます。

1. extensionsをカスタムする

まずは、標準仕様でも定義されている errors 内の extensionsをカスタムする方法です。

以下にレスポンス例を掲載します。

{
  "data": ...,
  "errors": [
    {
      "message": "Invalid email format",
      "locations": [...],
      "path": [...],
      "extensions": {
        "code": "INVALID_EMAIL_FORMAT",
        "message": "入力されたメールの形式が正しくありません。",
        "field": "email"
      }
    }
  ]
}

ユーザーが理解できる業務エラーメッセージとして、 extensions 配下に message を定義しています。

この方法を選択することで以下のメリットが見込めそうです。

  • 標準仕様である
  • スキーマの記述量が増えない

一方、以下のデメリットが考えられます。

  • extensions はハッシュでありスキーマ管理されないため、型によるエラーフォーマットの強制ができない
  • 業務エラーとシステムエラーが混同する可能性がある

なお、弊社の一部アプリケーションではこちらの方法が選択されていました。

■ 企業事例

https://techblog.zozo.com/entry/graphql_error_handling

https://note.com/tabelog_frontend/n/n4fc8d4e134d5#c34ea47a-7345-45b0-b0ce-e607579e2169

2. dataにuserErrorsを持たせる

次に、dataの中にuserErrorsを持たせる方法です。

オブジェクトタイプの中に userErrors を定義することで、クライアントでエラーを明示的に取得するようにします。

type UpdateUserResponse {
  user: User
  userErrors: [UserError!]!
}

type UserError {
  field: String!
  message: String!
}

以下にレスポンス例を掲載します。

{
  "data": {
    "updateUser": {
      "user": null,
      "userErrors": [
        {
          "field": "email",
          "message": "メールの形式が無効です"
        }
      ]
    }
  },
  "errors": ...
}

この方法を選択することで以下のメリットが見込めそうです。

  • userErrorsがスキーマ定義に含まれるため、型システムの恩恵を受けることができる
  • クライアントがユーザーエラーの取得を制御できる

一方、以下のデメリットが考えられます。

  • クライアントがユーザーエラーの存在に気づかない可能性がある
    • 口頭による伝達やスキーマの確認が漏れていると、userErrorsの存在に気付けない

■ 企業事例

https://github.com/Shopify/graphql-design-tutorial/blob/master/lang/TUTORIAL_JAPANESE.md#出力

https://zenn.dev/hacobell_dev/articles/graphql-error-response

3. union型で成功と失敗を表すレスポンスを定義する

最後に、union型で成功と失敗を表すレスポンスを定義する方法です。

updateUserPayload としてMutationの成功を表す UserUpdateSuccess と失敗を表す InvalidEmailUserNameTaken を定義しています。

type UserUpdateSuccess {
  user: User!
}

type InvalidEmail {
  field: String!
  message: String!
}

type UserNameTaken {
  field: String!
  message: String!
}

union UpdateUserPayload = UserUpdateSuccess | InvalidEmail | UserNameTaken

以下にレスポンス例を掲載します。

{
  "data": {
    "updateUser": {
      "field": "email",
      "message": "メールの形式が無効です"
    }
  },
  "errors": ...
}

この方法を選択することで以下のメリットが見込めそうです。

  • エラーフォーマットがスキーマに含まれるため、型システムの恩恵を受けられる
  • クライアントはユーザーエラー定義の存在に気づくことができる
  • ユーザーエラーのパターンをスキーマ上で確認できる

一方、以下のデメリットが考えられます。

  • 標準である extensions を利用する方法と大きく方針が異なり、学習コストがかかる印象
  • すべてのエラーコードを定義すると、スキーマが肥大化する

■ 企業事例
https://techblog.gaudiy.com/entry/2022/02/17/215331

結局、どの方法を検討しているのか

結論

「2. dataにuserErrorsを持たせる」が良さそうだと思いつつ、「1. extensionsをカスタムする」も検討している状態です。

チームの特徴と求める要件

「2. dataにuserErrorsを持たせる」が良さそうだと思う理由を説明するために、所属チームの特徴を説明します。

現在所属しているチームは、ココナラテックエージェントFuturizm を始め、いくつかの新規事業を担当しています。どのサービスも立ち上がったばかりでありサービス規模も小さいため、高速で施策を回してユーザーからのフィードバックを得る必要があります。そこで開発チームには、実装速度を意識して機能を作ることが求められます。

そのため、エラーハンドリングについても「シンプルであり実装コストやキャッチアップコストが小さい」という欲求を満たしたいです。一方で「堅牢な作りにする」という点は多少許容できそうです。

「dataにuserErrorsを持たせる」方法が良さそうだと考える理由

まずは、「3. union型で成功と失敗を表すレスポンスを定義する」については、堅牢に作り込めそうではありますが、開発コストや最初のキャッチアップコストがかかりそうな印象なため、選択肢からは外しました。実際に以下の企業では、最初はunion型で運用をしていたものの開発工数が想定よりもかかってしまいuserErrorsを利用する運用に切り替えているようです。

https://zenn.dev/hacobell_dev/articles/graphql-error-response

次に、「2. dataにuserErrorsを持たせる」が良さそうだと考える理由はエラーをスキーマ定義に含められる点です。型定義があることで、フロントエンドとバックエンド間でエラーフォーマットの認識齟齬が発生せずに安全に開発ができそうです。

ここまで聞くと2番で良さそうな気はしますが、1番も検討しています。

「extensionsをカスタムする」方法も検討している理由

現在、ココナラでは ココナラ経済圏構想 を掲げており、各サービスで獲得した情報は共通データベースとして集約していきたいと考えています。つまり、これまで以上に各サービスが連携する機会が増えていき、以前まではチーム内での最適解を見つけていればよかったものが、全社的な視点を意識しながら開発を進めていく必要があります。その中で、「1. extensionsをカスタムする」については、社内ですでに多少の知見が蓄積されており選択の余地がありそうです。

最後に

今回はGraphQLのエラーハンドリングについて考えてみました。

実際に運用を始めていくのはこれからなので、運用の中に気づきがあればまたブログ記事を書こうと思います。

ココナラはビジネス的に見ても技術的に見ても非常に面白いフェーズです。ココナラ経済圏構想 の話含め興味を持っていただいた方はぜひご応募ください!

https://coconala.co.jp/recruit/engineer/

Discussion