GraphQL Serverを実装する場合のエラー表現と例外処理について整理しつつ、graphql-rubyを使ってそれらを実装する方法について書いておく。
まずは、HTTPサーバーからGraphQLスキーマを返す場合に、エラーをステータスコードやレスポンスのbodyとして、どのように表現するかについて整理しておく。
まずは、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の使用が推奨されており、エラー種別に応じた、返し方についてもいくつか変更点がある。
表にあるIDでいうところの1~6はこのエラーに該当して、クエリの実行前に発生するGraphQLの文法やリクエストパラメータに問題がある場合のエラー。主にクライアントのリクエストのミスにおよって引き起こされる。
このエラーが発生すると、errorsキーのみが含まれる形でレスポンスが返される。
表にあるIDでいうところの7が該当して、特定のフィールドを実行している際に発生するエラーのこと、値の解決(resolve)や結果値の出力に失敗した場合などに起きる。 基本的に、フィールドエラーはGraphQLサーバーの不具合によって起き、もしフィールドエラーが発生した場合は、処理を継続して部分的な結果だけでも返そうとする。 レスポンス中にdataが必ず含まれ、errorにもエラーが発生したフィールドすべてが含まれている必要がある。
エラー時のスキーマについて。
https://spec.graphql.org/October2021/#sec-Response-Format
上記のエラーフォーマットは、開発者向けのエラーフォーマットとしては有用である一方、業務エラーとして、サービスの利用者向けにフィードバックを返したい場合においては、エラーの発生したドメインやエラーメッセージをクライアントアプリケーションで選択することが難しく、使いづらかったりする。
Production Ready GraphQLでは、errorsを更に
に分け、開発者やクライアント起因で発生するエラーはDeveloper/Client Errors、業務エラーはUser Errorsとして、正常系のスキーマの一部として設計するのがベストプラクティスとされている。 以下はその実装パターンである。
起こり得るエラーに関して、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 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 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や機能の少ないシンプルなアプリケーションなどであれば、グローバルレベルで例外を補足してもよいかもしれないが、基本的には、フィールド解決関連の例外補足(ユーザー入力の伴わない参照系の例外のみ)のユースケース以外では使わない方がよさそう。
Railsにおいては、Active Recordの例外をGraphQLサーバーのresolverで最終的なエラー表現に変換することが多いと考えている。 そのため、Active RecordのエラーをGraphQLサーバーのresolverでどのように扱っていくと収まりがよいかについて整理しておく。
前述したように、エラーがあったことを知らせたい対象が、クライアントアプリケーションの開発者なのか?エンドユーザーなのかに応じて、Top Level ErrorsとErrors As Schemaを使い分けることになる。 また、破壊的メソッドを使用してTop Levelまで例外を持っていくのは最小限にとどめる。
以下ではQueryとMutationsのresolverそれぞれにおける、エラー制御の実装方針について考えていくことにする
例外がハンドリングされることなく、フレームワークのTop Levelまで突き抜けて500エラーになる。
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
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として扱う
例外がハンドリングされることなく、フレームワークの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