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 エラーになる。
例えば、想定される業務エラーがなく 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