GraphQLにおけるエラーハンドリングの仕方

f:id:vasilyjp:20190315124436p:plain

こんにちは、サーバーサイドエンジニアの竹若です。今回GraphQLにおけるエラーハンドリングを調査、Ruby on Railsとgraphql-rubyを使って実装する機会があったので、そこで得られた知見を共有させていただきたいと思います。(なお今回の実装はプロダクション環境には出ていません)

GraphQLの仕様とプラクティス

それではまず初めに、GraphQLが仕様に定めているレスポンスの返し方を見ていきましょう。

レスポンスのフォーマットに関するプラクティス

GraphQLのプラクティスの1つに、レスポンスのhttp statusを200で統一し、レスポンスのerrorsキーにエラーの詳細な情報を持たせるというものがあります。
なぜならGraphQLではリクエストに複数のクエリを含めることができるからです。

https://www.graph.cool/docs/faq/api-eep0ugh1wa/#how-does-error-handling-work-with-graphcool

Since GraphQL allows for multiple operations to be sent in the same request, it's well possible that a request only partially fails and returns actual data and errors.

これはあくまでプラクティスであり仕様ではないのですが、周辺ツール(Apolloやgraphql-ruby)がこのプラクティスに従っているため私たちも基本的には従うことになります。

レスポンスのフォーマットに関する仕様

ではGraphQLはどのようにしてエラーを表現するのでしょうか? GraphQLの仕様を見てみましょう。
https://facebook.github.io/graphql/June2018/#sec-Errors

GraphQLの仕様ではレスポンスのフォーマットはハッシュであり、中にdataerrorsというキーを含みます。

{
  "errors": [
    {
      "message": "hogehoge",
      "extensions": {
        "bar": "bar"
      }
    }
  ],
  "data": {
    "user": {
      "name": "takewaka"
    }
  }
}

data
クエリの実行結果が入るキーです。
クエリの実行前にエラーが発生した場合、dataキーはレスポンスに含まれません。

errors
クエリの実行中に発生したエラーが入るキーです。
クエリの実行中にエラーが発生しなかった場合、errorsキーはレスポンスに含まれません。
またレスポンスにdataキーが含まれない場合、errorsキーは必ずレスポンスに含まれている必要があります。

なおerrorsのフォーマットも仕様で定められていて、中にmessage,location,pathというキーが含まれます。(locationpathはクエリの中にエラーの該当箇所が存在する場合にのみ含まれます)
上記3つのキーの他にキーを追加したい場合は、extensionsというキーを用意してその中に追加する仕様です。
なぜならmessagepathなどのキーと同じレベルにオリジナルのキーを追加してしまうと、そのオリジナルのキーと将来的に仕様に追加されるキーがバッティングを起こす可能性があるからです。

https://facebook.github.io/graphql/June2018/#sec-Errors

GraphQL services should not provide any additional entries to the error format since they could conflict with additional entries that may be added in future versions of this specification.

GraphQLを使っていて発生するエラーとその分類

さて、GraphQLがどのようにしてエラーを表現するかはわかりました。
次にGraphQLを使っていて起こるエラーにはどのようなものがあるのか見ていきましょう。

エラーを以下の2つの観点で見ていきます。

  • エラーの原因はクライアントなのか、サーバーサイドなのか
  • エラーはどこで発生したか

クライアントが原因のエラーは主に以下の3つに分類できます。

  • パースエラー
    • クエリのシンタックスエラー
  • バリデーションエラー
    • クエリが型チェックで引っかかる
  • クエリ実行時エラー
    • 認証失敗など

サーバーサイドが原因のエラーはRailsで実装したロジックのエラーです。

どのようなエラーが存在するかわかったところで、それぞれのエラーをどのような形のレスポンスで表現するのか見てみましょう。

Apolloなどのクライアントがレスポンスをパースしやすいように、レスポンスのフォーマットはGraphQLの仕様に準拠した形で統一したいです。
そこでerrorsキーの中のmessageキーにエラーの詳細なメッセージを入れて、extensionsキーの中のcodeキーにステータスコードを入れる方法をここでは見ていきます。
これはGraphQLの仕様書に載っているエラーレスポンスの例と同じ方法です。
またApollo Serverでは、extensionsキーの中にcodeをはじめとしたエラーに関するキーを含める方法を採用しています。
例えば認証エラーであればこのようにcodeの中にAUTHENTICATION_ERRORというステータスを入れます。

"errors": [
    {
      "message": "permission denied",
      "locations": [],
      "extensions": {
        "code": "AUTHENTICATION_ERROR"
      }
    }
  ]

サーバーエラーの場合はこのようにcodeの中にINTERNAL_SERVER_ERRORというステータスを入れます。

"errors": [
    {
      "message": "undefined method 'hoge' for nil",
      "locations": [],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR"
      }
    }
  ]

graphql-rubyで実装する方法

エラーをどのような形のレスポンスで表現するか決まったところで、graphql-rubyで実際に実装していきましょう。

graphql-rubyではエラーをどう拾ってどう返すか

graphql-rubyではGraphQL::ExecutionErrorかもしくはそのサブクラスをraiseすることでerrorsにエラーを含めることができます。

def resolve(name:)
  user = User.new(name: name)
  if user.save
    { user: user }
  else
    raise GraphQL::ExecutionError, user.errors.full_messages.join(", ")
  end
end

認証エラー

例として認証エラーの実装を載せます。
この例ではログインをセッションで管理していてcurrent_userメソッドを呼ぶことでユーザーオブジェクトが取得できる設定です。
コントローラーでGraphQL::Schema#executeを実行する際にユーザーのログイン情報をcontextに入れて引数として持たせておきます。

class GraphqlController < ApplicationController
  def execute
    variables = ensure_hash(params[:variables])
    query = params[:query]
    operation_name = params[:operationName]
    context = { current_user: current_user }
    result = SampleSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
    render json: result
    #...
  end
  #...
end

resolveメソッドの中でcontextに入っているユーザーのログイン情報を見て認証エラーを吐かせています。
GraphQL::ExecutionErrorはキーワード引数としてextensionsを持っているのでオリジナルのキー(ここでいうcode)を渡すことができます。

def resolve(name:, sex:)
  raise GraphQL::ExecutionError.new('permission denied', extensions: { code: 'AUTHENTICATION_ERROR' }) unless context[:current_user]

  #...
end

そうすることで認証エラーが発生した場合、このようなフォーマットでレスポンスを返すことができます。

"errors": [
    {
      "message": "permission denied",
      "locations": [
        {
          "line": 3,
          "column": 3
        }
      ],
      "path": [
        "createUser"
      ],
      "extensions": {
        "code": "AUTHENTICATION_ERROR"
      }
    }
  ]

セーフティネット

GraphQLのレスポンスはクライアントがパースしやすいようにフォーマットを統一することが重要です。
発生したエラーが最後までrescueされずにいるとRailsの一般的な500エラーが返ってしまいます。
そうなるとクライアント側はhttp status 200で返ってくるGraphQLのエラーと、Railsの一般的な500エラーの両方をパースする準備をしなければなりません。
そこでサーバーサイドでエラーを最終的に受け止めるセーフティネットを用意したくなります。
graphql-ruby 1.8まではrescue_fromメソッドを使ってこれを実現できます。
以下にrescue_fromメソッドを使った実装例を示します。

class SampleSchema < GraphQL::Schema
  rescue_from(StandardError) { 'INTERNAL_SERVER_ERROR' }

#...
end

こうすることでRailsの一般的な500エラーではなく、以下のようなGraphQLのエラーを返すことができます。

"errors": [
    {
      "message": "INTERNAL_SERVER_ERROR",
    }
  ]

ただrescue_fromの欠点として、errors内のmessageキーの内容しか指定できないという点があります。
これはgraphql-errorsというgemを使ってrescue_fromにエラークラスのオブジェクトを渡すことで解決します。
GitHub - exAspArk/graphql-errors: Simple error handler for GraphQL Ruby

しかしgraphql-rubyの機能としてrescue_fromにエラーオブジェクトを渡せてもいいのではないかと考えたのでパッチを書きました。
extend GraphQL::Schema::RescueMiddleware#attempt_rescue by masakazutakewaka · Pull Request #2140 · rmosolgo/graphql-ruby · GitHub

このパッチは以下のようにブロックにGraphQL::ExecutionErrorオブジェクトを渡せるようにすることでextensionsキーを使えるようにするものです。

class SampleSchema < GraphQL::Schema
  rescue_from(StandardError) do |message|
    GraphQL::ExecutionError.new(message, extensions: {code: 'INTERNAL_SERVER_ERROR'})
  end

#...
end

またこのrescue_fromメソッドはgraphql-ruby 1.9から使えなくなります。
理由はrescue_fromメソッドが定義されているGraphQL::Schema::RescueMiddlewareクラスがgraphql-ruby 1.9から使えなくなるからです。
GraphQL - Interpreter

graphql-ruby 1.9では現状rescue_fromメソッドに変わる何かは存在せず、どのような実装が追加されるかも未定というステータスです。
GraphQL::Execution::Interpreter and rescue_from compatibility · Issue #2139 · rmosolgo/graphql-ruby · GitHub

個人的にはgraphql-errorsがgraphql-rubyに上手く取り込まれてくれたらいいなと思っています。

複数エラー

クエリを実行して発生した複数のエラーを1つのレスポンスに含めたい場合があります。
例えばユーザー登録などの複数の入力項目を持つMutationがあったとします。
入力内容が不正であった全ての入力項目にエラーメッセージを表示したい場合、複数のエラーをレスポンスに含めたくなります。
graphql-rubyにおいてはGraphQL::Schema::Context#add_errorを使うことで複数エラーをレスポンスに含めることができます。
https://www.rubydoc.info/github/rmosolgo/graphql-ruby/GraphQL%2FQuery%2FContext%2FFieldResolutionContext:add_error

以下は実装例です。

module Mutations
  class CreateUser < GraphQL::Schema::RelayClassicMutation
    argument :name, String, required: true
    argument :sex, String, required: true

    field :user, Types::UserType, null: true

    def resolve(name:, sex:)
      user = User.new({ name: name, sex: sex })
      if user.save
        { user: user }
      else
        build_errors(user)
        return # これがないとrescue_fromに拾われてしまう
      end
    end

    def build_errors(user)
      user.errors.map do |attr, message|
        message = user[attr] + ' ' + message
        context.add_error(GraphQL::ExecutionError.new(message, extensions: { code: 'USER_INPUT_ERROR', attribute: attr }))
      end
    end
  end
end

複数のエラーを含んだレスポンスは以下のようになります。

"errors": [
    {
      "message": "hoge はすでに存在します",
      "extensions": {
        "code": "USER_INPUT_ERROR",
        "attribute": "name"
      }
    },
    {
      "message": "fuge は一覧にありません",
      "extensions": {
        "code": "USER_INPUT_ERROR",
        "attribute": "sex"
      }
    }
  ]

また独自のエラータイプを定義してエラーの内容をdataに含めるという方法も存在します。
https://github.com/rmosolgo/graphql-ruby/blob/master/guides/mutations/mutation_errors.md#errors-as-data

しかしGraphQL::Schema::Context#add_errorを使う方法を以下の理由で採用しました。

  • GraphQLの仕様上errorsdataと同じレベルに位置してる
  • dataの中にエラーを入れる場合、クエリ内にエラーのフィールドを明示的に書かないとエラーの情報を得ることができないのでエラーの受け渡し方として優れていない

独自のエラータイプを定義してエラーの内容をdataに含める方法にも以下のような利点があると思います。

  • エラータイプを定義するのでエラーの構造をスキーマで共有できる
    • クライアント側でエラーメッセージを表示したい場合に、エラーの情報をレスポンスから取り出すのが楽

まとめ

この記事ではGraphQLにおけるエラーハンドリングの仕方とgraphql-rubyを使った実装例を紹介しました。
この記事の初めにGraghQLの仕様に軽く触れましたが、GraphQLの仕様はとても簡潔にまとめられているので読むことをお勧めします。
また紹介したgraphql-ruby には見やすい場所にドキュメントされていない隠れAPIがあったりするので、ソースコードやissueを読んでみると色々発見できると思います。
この記事の最後の方で紹介したadd_errorメソッドが隠れAPIの1つです。
将来的にはプロダクションに出してから得られる知見も発信したいです。
GraphQLを使った開発に興味のある方がいましたら、ぜひ以下のリンクからご応募ください。お待ちしております!

www.wantedly.com

参考

カテゴリー