kakudooo docs

GraphQL Server とエラー処理

GraphQL Server を実装する場合のエラー表現と例外処理について整理しつつ、graphql-ruby を使ってそれらを実装する方法について書いておく。

GraphQL でエラーを表現する方法

まずは、HTTP サーバーから GraphQL スキーマを返す場合に、エラーをステータスコードやレスポンスの body として、どのように表現するかについて整理しておく。

HTTP ステータスコード

まずは、HTTP のステータスコードから

基本的に上記のみ使用することが推奨される。(content type が application/json の場合)

以下は、GraphQL の仕様書にあるエラー種別とステータスコードを整理したもの

ID エラー種別 推奨ステータスコード
1 JSON parsing failure 400
2 Invalid parameters 400
3 Document parsing failure 200
4 Document validation failure 200
5 Operation cannot be determined 200
6 Variable coercion failure 200
7 Field errors encountered during execution (GraphQL の query や mutation の実行時エラー) 200

JSON 形式で正しくフォーマットされたリクエストに対してはステータスコード 200 がレスポンスとして返され、JSON 形式が不正なフォーマットなリクエストに関しては 400 が返されるべきであるという仕様である。 GraphQL Over HTTP の次期仕様では、application/graphql-response+json content type の使用が推奨されており、エラー種別に応じた、返し方についてもいくつか変更点がある。

Request errors

表にある ID でいうところの 1~6 はこのエラーに該当して、クエリの実行前に発生する GraphQL の文法やリクエストパラメータに問題がある場合のエラー。主にクライアントのリクエストのミスにおよって引き起こされる。

このエラーが発生すると、errors キーのみが含まれる形でレスポンスが返される。

Field errors

表にある ID でいうところの 7 が該当して、特定のフィールドを実行している際に発生するエラーのこと、値の解決(resolve)や結果値の出力に失敗した場合などに起きる。 基本的に、フィールドエラーは GraphQL サーバーの不具合によって起き、もしフィールドエラーが発生した場合は、処理を継続して部分的な結果だけでも返そうとする。 レスポンス中に data が必ず含まれ、error にもエラーが発生したフィールドすべてが含まれている必要がある。

スキーマ

エラー時のスキーマについて。

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

https://spec.graphql.org/October2021/#sec-Response-Format

上記のエラーフォーマットは、開発者向けのエラーフォーマットとしては有用である一方、業務エラーとして、サービスの利用者向けにフィードバックを返したい場合においては、エラーの発生したドメインやエラーメッセージをクライアントアプリケーションで選択することが難しく、使いづらかったりする。

Production Ready GraphQL では、errors を更に

に分け、開発者やクライアント起因で発生するエラーは Developer/Client Errors、業務エラーは User Errors として、正常系のスキーマの一部として設計するのがベストプラクティスとされている。 以下はその実装パターンである。

User Errors の実装パターン

Errors as Data パターン

起こり得るエラーに関して、Payload Type に field を追加する。

エラーをデータとして設計する最も簡単な例(起こりうるエラーに関して Payload Type に field を追加する)

type SignUpPayload {
  emailWasTaken: Boolean!
  # nil if the Account could not be created
  account: Account
}

# userErrors fieldのようなものを、Payload Typeに含めることで、よりよいアプローチとした例(クライアントが任意でエラーを扱えるようにする)

type SignUpPayload {
  userErrors: [UserError!]!
  account: Account
}

type UserError {
  # The error message
  message: String!

  # Indicates which field cause the error, if any
  # Field is an array that acts as a path to the error
  # Example:
  # ["accounts", "1", "email"]
  field: [String!]

  # An optional error code for clients to match on.
  code: UserErrorCode
}
Errors as Union / Result パターン

エラー用に特定のフィールドを作成する Errors as Data パターンに対して、リクエストの結果を状態として、Union Type を使用して表現する方法。 Errors as Data パターンに対して、より宣言的に GraphQL スキーマを記述することができ、かつ GraphQL の強力な型システムを使用することができる。

type Mutation {
  signUp(email: string!, password: String!): SignUpPayload
}

union SignUpPayload = SignUpSuccess | UserNameTaken | PasswordTooWeak

mutation {
  signUp(email: "hog@example.com", password: "Password")
}
{
  ... on SignUpSuccess {
    account {
      id
    }
  }

  ... on UserNameTaken {
    message
    suggestedUsername
  }

  ... on PasswordTooWeak {
    message
    passwordRules
  }
}

どっちを使えばいいのか?

どちらも効果的ではあるが、

実装および運用コスト、スキーマ定義の厳格さを天秤にかけて検討するとよい。

graphql-ruby による GraphQL Server の実装とエラー処理

graphql-ruby で GraphQL Server を実装する場合のエラー表現についての話し。

GraphQL Server の仕様と、graphql-ruby で定義されているエラー分類を対応させてみると以下のようになる。

エラーの種類 GraphQL Spec との対応 ステータスコード 概要
Validation Errors 1,2 200 クエリの実行前バリデーションエラー
Analysis Errors 3 200 実行前のスキーマ分析時のエラー
GraphQL Invaliants 4,5,6 200 GraphQL の型エラー
Top Level Errors 7 200 クエリの実行時エラー
Handled Errors 7 200 制御されたエラー
Unhandled Errors 7 500 例外が rescue されずに Top Lelvel まで到達した場合のエラー
Errors as Schema 7 200 エンドユーザー向けのエラースキーマを使用する場合のエラー、Errors as Data と Errors as Union がある

Validation Errors、Analysis Errors、GraphQL Invaliants は graphql-ruby がフレームワークレベルでよしなにやってくれる。任意でカスタムすることもできる。 実装者が主に意識するエラーは Top Level Errors、Handled Errors、Unhandled Errors、Errors as Schema である。

特に、制御エラーである Handled Errors について見ていきたい。

ポイントとしては、なるべく Query や Mutation の resolver で例外を補足して、エラーの種類に応じて、

のいずれかのエラー表現を選択することになる。

graphql-ruby の公式ドキュメントでは、エラーハンドリングの例として、以下が紹介されている。

class MySchema < GraphQL::Schema
	rescue_from(ActiveRecord::RecordNotFound) do |err, obj, args, ctx, field|
		raise GraphQL::ExecutionError, "#{field.type.unwrap.graphql_name} not found"
	end

	rescue_from(SearchIndex::UnavailableError) do |err, obj, args, ctx, field|
		Bugsnag.notify(err)
		nil
	end
end

You can configure your schema to rescue application errors during field resolution. Errors during batch loading will also be rescued.

とあるように、フィールドの解決時に発生したアプリケーションエラーを補足するためのもので、また、バッチローディングのエラーも補足することができる。

rescue_from でスキーマにおけるグローバルレベルで例外を補足することができる一方で、フィールド解決やバッチローディング以外の例外補足の目的でも使用できてまい、Schema クラスの肥大化や処理の複雑化につながってしまう可能性がある。 MPV や機能の少ないシンプルなアプリケーションなどであれば、グローバルレベルで例外を補足してもよいかもしれないが、基本的には、フィールド解決関連の例外補足(ユーザー入力の伴わない参照系の例外のみ)のユースケース以外では使わない方がよさそう。

【graphql-ruby】Active Record との立て付け方

Rails においては、Active Record の例外を GraphQL サーバーの resolver で最終的なエラー表現に変換することが多いと考えている。 そのため、Active Record のエラーを GraphQL サーバーの resolver でどのように扱っていくと収まりがよいかについて整理しておく。

前述したように、エラーがあったことを知らせたい対象が、クライアントアプリケーションの開発者なのか?エンドユーザーなのかに応じて、Top Level Errors と Errors As Schema を使い分けることになる。 また、破壊的メソッドを使用して Top Level まで例外を持っていくのは最小限にとどめる。

以下では Query と Mutations の resolver それぞれにおける、エラー制御の実装方針について考えていくことにする

Query(Field) Resolver

システムエラー

例外がハンドリングされることなく、フレームワークの Top Level まで突き抜けて 500 エラーになる。

業務エラー

クライアントアプリケーションの開発者向けのエラーにする場合
  1. バッチ処理や、RecordNotFound のような参照系の汎用的な例外を扱う場合
class MySchema < GraphQL::Schema
	rescue_from(ActiveRecord::RecordNotFound) do |err, obj, args, ctx, field|
		raise GraphQL::ExecutionError, "#{field.type.unwrap.graphql_name} not found"
	end

	rescue_from(SearchIndex::UnavailableError) do |err, obj, args, ctx, field|
		Bugsnag.notify(err)
		nil
	end
end

ActiveRecord のエラーをそのまま例外として Schema まで突き抜けさせる

def resolver(id)
	User.find(id) # 存在しない場合は ActiveRecord::RecordNotFound Error
end
  1. Resolver で GraphQL::ExecutionError を raise して Top Level Errors にする場合
raise GraphQL::ExecutionError, "Can't continue with this query"

extensions でカスタムエラーにしてもよい

raise GraphQL::ExecutionError.new("Something went wrong", extensions: { "code" => "BROKEN" })

GraphQL::ExecutionError を継承したエラークラスを作ってもよい

class ServiceUnavailableError < GraphQL::ExecutionError
  def to_h
    super.merge({ "extensions" => {"code" => "SERVICE_UNAVAILABLE"} })
  end
end

ActiveRecord のエラーをエラーオブジェクトで受け取る or rescue して、Top Level Errors にする(参照系で必要になることは少ないはず)

推奨

def resolve
	@user = User.find_by(name: 'hoge')

	if @user.present?
		@user
	else
		raise GraphQL::ExecutionError.new(...)
	end
end

非推奨(汎用的な参照系の例外以外は Controller(resolver)になるべく例外処理を持ち込まない)

def resolve
	# 例外はModelでハンドリングする
	User.find_by!(name: 'hoge')
rescue
	raise GraphQL::ExecutionError.new(...)
end
ユーザー向けのエラーにする場合

参照系の処理ではあまりないパターンではある。 例えば、検索機能の入力値チェックや対象レコードの取得エラーなどが考えられる

上述の Error As Schema パターンで実装する

ActiveRecord のエラーをエラーオブジェクトで受け取って、Error As Schema の Field として扱う

Mutations Resolver

システムエラー

例外がハンドリングされることなく、フレームワークの Top Level まで突き抜けて 500 エラーになる。

例えば、想定される業務エラーがなく resolver で例外のハンドリングが不要な場合などが考えれる。

def resolve
  @user = User.find(...)
  @user.last_session_at = Time.zone.now

  # 想定される業務エラーがないので、!系のメソッドを直接呼び出してもOK
  @user.save!
  ...
end

業務エラー

クライアント向けのエラーにする場合

個別箇所で GraphQL::ExecutionError を raise して Top Level Errors として扱う場合

raise GraphQL::ExecutionError, "Can't continue with this query"

extensions でカスタムエラーにしてもよい

raise GraphQL::ExecutionError.new("Something went wrong", extensions: { "code" => "BROKEN" })

GraphQL::ExecutionError を継承したエラークラスを作ってもよい

class ServiceUnavailableError < GraphQL::ExecutionError
  def to_h
    super.merge({ "extensions" => {"code" => "SERVICE_UNAVAILABLE"} })
  end
end

ActiveRecord のエラーをエラーオブジェクトで受け取って、Top Level Errors にする

推奨

def resolve
  @user = User.find(...)
	@user.name = 'hoge'

	if @user.save
		@user
	else
		raise GraphQL::ExecutionError.new(...)
	end
end

非推奨(Controller(resolver)になるべく例外処理を持ち込まない)

def resolve
  @user = User.find(...)
	@user.update!(name: 'hoge')
rescue
	raise GraphQL::ExecutionError.new(...)
end
ユーザー向けのエラーにする場合

上述の Error As Schema パターンで実装する

ActiveRecord のエラーをエラーオブジェクトで受け取る or rescue して、Error As Schema の Field として扱う

https://graphql-ruby.org/mutations/mutation_errors.html

参考