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エラーになる。

業務エラー

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

個別箇所で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.update(name: 'hoge')

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

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

def resolve
	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

参考