Skip to content

Commit 8694844

Browse files
authored
Merge pull request #2458 from rmosolgo/interpreter-errors
Add error handling for the interpreter
2 parents 21398ec + 6ca3709 commit 8694844

File tree

6 files changed

+258
-17
lines changed

6 files changed

+258
-17
lines changed

guides/errors/error_handling.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
layout: guide
3+
doc_stub: false
4+
search: true
5+
section: Errors
6+
title: Error Handling
7+
desc: Rescuing application errors from field resolvers
8+
index: 3
9+
---
10+
11+
You can configure your schema to rescue application errors during field resolution. Errors during batch loading will also be rescued.
12+
13+
__Note:__ This feature is for class-based schemas using the {% internal_link "interpreter runtime", "/queries/interpreter" %} only. For `.define`-based schemas, use [exAspArk/graphql-errors](https://github.com/exaspark/graphql-errors) instead.
14+
15+
Thanks to [`@exAspArk`] for the [`graphql-errors`](https://github.com/exAspArk/graphql-errors) gem which inspired this behavior and [`@thiago-sydow`](https://github.com/thiago-sydow) who [suggested](https://github.com/rmosolgo/graphql-ruby/issues/2139#issuecomment-524913594) and implementation like this.
16+
17+
## Setup
18+
19+
Add error handling to your schema with `use GraphQL::Execution::Errors`. (This will be the default in a future graphql-ruby version.)
20+
21+
```ruby
22+
class MySchema < GraphQL::Schema
23+
# Use the new runtime & analyzers:
24+
use GraphQL::Execution::Interpreter
25+
use GraphQL::Analysis::AST
26+
# Also use the new error handling:
27+
use GraphQL::Execution::Errors
28+
end
29+
```
30+
31+
## Add error handlers
32+
33+
Handlers are added with `rescue_from` configurations in the schema:
34+
35+
```ruby
36+
class MySchema < GraphQL::Schema
37+
# ...
38+
39+
rescue_from(ActiveRecord::NotFound) do |err, obj, args, ctx, field|
40+
# Raise a graphql-friendly error with a custom message
41+
raise GraphQL::ExecutionError, "#{field.type.unwrap.graphql_name} not found"
42+
end
43+
44+
rescue_from(SearchIndex::UnavailableError) do |err, obj, args, ctx, field|
45+
# Log the error
46+
Bugsnag.notify(err)
47+
# replace it with nil
48+
nil
49+
end
50+
end
51+
```
52+
53+
The handler is called with several arguments:
54+
55+
- __`err`__ is the error that was raised during field execution, then rescued
56+
- __`obj`__ is the object which was having a field resolved against it
57+
- __`args`__ is the the Hash of arguments passed to the resolver
58+
- __`ctx`__ is the query context
59+
- __`field`__ is the {{ "GraphQL::Schema::Field" | api_doc }} instance for the field where the error was rescued
60+
61+
Inside the handler, you can:
62+
63+
- Raise a GraphQL-friendly {{ "GraphQL::ExecutionError" | api_doc }} to return to the user
64+
- Re-raise the given `err` to crash the query and halt execution. (The error will propagate to your application, eg, the controller.)
65+
- Report some metrics from the error, if applicable
66+
- Return a new value to be used for the error case (if not raising another error)

guides/errors/overview.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ The GraphQL specification provides for a top-level `"errors"` key which may incl
4646

4747
In your own schema, you can add to the `"errors"` key by raising `GraphQL::ExecutionError` (or subclasses of it) in your code. Read more in the {% internal_link "Execution Errors guide", "/errors/execution_errors" %}.
4848

49+
## Handled Errors
50+
51+
A schema can be configured to handle certain errors during field execution with handlers that you give it, using `rescue_from`. Read more in the {% internal_link "Error Handling guide", "/errors/error_handling" %}.
52+
4953
## Unhandled Errors (Crashes)
5054

5155
When a `raise`d error is not `rescue`d, the GraphQL query crashes entirely and the surrounding code (like a Rails controller) must handle the exception.

lib/graphql/execution.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
require "graphql/execution/lookahead"
99
require "graphql/execution/multiplex"
1010
require "graphql/execution/typecast"
11+
require "graphql/execution/errors"

lib/graphql/execution/errors.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL
4+
module Execution
5+
# A tracer that wraps query execution with error handling.
6+
# Supports class-based schemas and the new {Interpreter} runtime only.
7+
#
8+
# @example Handling ActiveRecord::NotFound
9+
#
10+
# class MySchema < GraphQL::Schema
11+
# use GraphQL::Execution::Errors
12+
#
13+
# rescue_from(ActiveRecord::NotFound) do |err, obj, args, ctx, field|
14+
# ErrorTracker.log("Not Found: #{err.message}")
15+
# nil
16+
# end
17+
# end
18+
#
19+
class Errors
20+
def self.use(schema)
21+
schema_class = schema.is_a?(Class) ? schema : schema.target.class
22+
schema.tracer(self.new(schema_class))
23+
end
24+
25+
def initialize(schema)
26+
@schema = schema
27+
end
28+
29+
def trace(event, data)
30+
case event
31+
when "execute_field", "execute_field_lazy"
32+
with_error_handling(data) { yield }
33+
else
34+
yield
35+
end
36+
end
37+
38+
private
39+
40+
def with_error_handling(trace_data)
41+
yield
42+
rescue StandardError => err
43+
rescues = @schema.rescues
44+
_err_class, handler = rescues.find { |err_class, handler| err.is_a?(err_class) }
45+
if handler
46+
obj = trace_data[:object]
47+
args = trace_data[:arguments]
48+
ctx = trace_data[:query].context
49+
field = trace_data[:field]
50+
if obj.is_a?(GraphQL::Schema::Object)
51+
obj = obj.object
52+
end
53+
handler.call(err, obj, args, ctx, field)
54+
else
55+
raise err
56+
end
57+
end
58+
end
59+
end
60+
end

lib/graphql/execution/interpreter/runtime.rb

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,17 @@ def evaluate_selections(path, owner_object, owner_type, selections, root_operati
205205

206206
field_result = resolve_with_directives(object, ast_node) do
207207
# Actually call the field resolver and capture the result
208-
app_result = query.trace("execute_field", {owner: owner_type, field: field_defn, path: next_path, query: query}) do
209-
field_defn.resolve(object, kwarg_arguments, context)
208+
app_result = begin
209+
query.trace("execute_field", {owner: owner_type, field: field_defn, path: next_path, query: query, object: object, arguments: kwarg_arguments}) do
210+
field_defn.resolve(object, kwarg_arguments, context)
211+
end
212+
rescue GraphQL::ExecutionError => err
213+
err
210214
end
211-
after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path) do |inner_result|
215+
after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, owner_object: object, arguments: kwarg_arguments) do |inner_result|
212216
continue_value = continue_value(next_path, inner_result, field_defn, return_type.non_null?, ast_node)
213217
if HALT != continue_value
214-
continue_field(next_path, continue_value, field_defn, return_type, ast_node, next_selections, false)
218+
continue_field(next_path, continue_value, field_defn, return_type, ast_node, next_selections, false, object, kwarg_arguments)
215219
end
216220
end
217221
end
@@ -274,15 +278,15 @@ def continue_value(path, value, field, is_non_null, ast_node)
274278
# Location information from `path` and `ast_node`.
275279
#
276280
# @return [Lazy, Array, Hash, Object] Lazy, Array, and Hash are all traversed to resolve lazy values later
277-
def continue_field(path, value, field, type, ast_node, next_selections, is_non_null)
281+
def continue_field(path, value, field, type, ast_node, next_selections, is_non_null, owner_object, arguments) # rubocop:disable Metrics/ParameterLists
278282
case type.kind.name
279283
when "SCALAR", "ENUM"
280284
r = type.coerce_result(value, context)
281285
write_in_response(path, r)
282286
r
283287
when "UNION", "INTERFACE"
284288
resolved_type_or_lazy = query.resolve_type(type, value)
285-
after_lazy(resolved_type_or_lazy, owner: type, path: path, field: field) do |resolved_type|
289+
after_lazy(resolved_type_or_lazy, owner: type, path: path, field: field, owner_object: owner_object, arguments: arguments) do |resolved_type|
286290
possible_types = query.possible_types(type)
287291

288292
if !possible_types.include?(resolved_type)
@@ -293,7 +297,7 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
293297
nil
294298
else
295299
resolved_type = resolved_type.metadata[:type_class]
296-
continue_field(path, value, field, resolved_type, ast_node, next_selections, is_non_null)
300+
continue_field(path, value, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments)
297301
end
298302
end
299303
when "OBJECT"
@@ -302,7 +306,7 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
302306
rescue GraphQL::ExecutionError => err
303307
err
304308
end
305-
after_lazy(object_proxy, owner: type, path: path, field: field) do |inner_object|
309+
after_lazy(object_proxy, owner: type, path: path, field: field, owner_object: owner_object, arguments: arguments) do |inner_object|
306310
continue_value = continue_value(path, inner_object, field, is_non_null, ast_node)
307311
if HALT != continue_value
308312
response_hash = {}
@@ -323,11 +327,11 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
323327
idx += 1
324328
set_type_at_path(next_path, inner_type)
325329
# This will update `response_list` with the lazy
326-
after_lazy(inner_value, owner: inner_type, path: next_path, field: field) do |inner_inner_value|
330+
after_lazy(inner_value, owner: inner_type, path: next_path, field: field, owner_object: owner_object, arguments: arguments) do |inner_inner_value|
327331
# reset `is_non_null` here and below, because the inner type will have its own nullability constraint
328332
continue_value = continue_value(next_path, inner_inner_value, field, false, ast_node)
329333
if HALT != continue_value
330-
continue_field(next_path, continue_value, field, inner_type, ast_node, next_selections, false)
334+
continue_field(next_path, continue_value, field, inner_type, ast_node, next_selections, false, owner_object, arguments)
331335
end
332336
end
333337
end
@@ -338,7 +342,7 @@ def continue_field(path, value, field, type, ast_node, next_selections, is_non_n
338342
inner_type = resolve_if_late_bound_type(inner_type)
339343
# Don't `set_type_at_path` because we want the static type,
340344
# we're going to use that to determine whether a `nil` should be propagated or not.
341-
continue_field(path, value, field, inner_type, ast_node, next_selections, true)
345+
continue_field(path, value, field, inner_type, ast_node, next_selections, true, owner_object, arguments)
342346
else
343347
raise "Invariant: Unhandled type kind #{type.kind} (#{type})"
344348
end
@@ -389,23 +393,23 @@ def resolve_if_late_bound_type(type)
389393
# @param field [GraphQL::Schema::Field]
390394
# @param eager [Boolean] Set to `true` for mutation root fields only
391395
# @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it.
392-
def after_lazy(obj, owner:, field:, path:, eager: false)
396+
def after_lazy(lazy_obj, owner:, field:, path:, owner_object:, arguments:, eager: false)
393397
@interpreter_context[:current_path] = path
394398
@interpreter_context[:current_field] = field
395-
if schema.lazy?(obj)
399+
if schema.lazy?(lazy_obj)
396400
lazy = GraphQL::Execution::Lazy.new(path: path, field: field) do
397401
@interpreter_context[:current_path] = path
398402
@interpreter_context[:current_field] = field
399403
# Wrap the execution of _this_ method with tracing,
400404
# but don't wrap the continuation below
401-
inner_obj = query.trace("execute_field_lazy", {owner: owner, field: field, path: path, query: query}) do
405+
inner_obj = query.trace("execute_field_lazy", {owner: owner, field: field, path: path, query: query, object: owner_object, arguments: arguments}) do
402406
begin
403-
schema.sync_lazy(obj)
407+
schema.sync_lazy(lazy_obj)
404408
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err
405409
yield(err)
406410
end
407411
end
408-
after_lazy(inner_obj, owner: owner, field: field, path: path, eager: eager) do |really_inner_obj|
412+
after_lazy(inner_obj, owner: owner, field: field, path: path, owner_object: owner_object, arguments: arguments, eager: eager) do |really_inner_obj|
409413
yield(really_inner_obj)
410414
end
411415
end
@@ -417,7 +421,7 @@ def after_lazy(obj, owner:, field:, path:, eager: false)
417421
lazy
418422
end
419423
else
420-
yield(obj)
424+
yield(lazy_obj)
421425
end
422426
end
423427

spec/graphql/execution/errors_spec.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
4+
describe "GraphQL::Execution::Errors" do
5+
class ErrorsTestSchema < GraphQL::Schema
6+
class ErrorA < RuntimeError; end
7+
class ErrorB < RuntimeError; end
8+
class ErrorC < RuntimeError
9+
attr_reader :value
10+
def initialize(value:)
11+
@value = value
12+
super
13+
end
14+
end
15+
16+
class ErrorASubclass < ErrorA; end
17+
18+
use GraphQL::Execution::Interpreter
19+
use GraphQL::Analysis::AST
20+
use GraphQL::Execution::Errors
21+
22+
rescue_from(ErrorA) do |err, obj, args, ctx, field|
23+
ctx[:errors] << "#{err.message} (#{field.owner.name}.#{field.graphql_name}, #{obj.inspect}, #{args.inspect})"
24+
nil
25+
end
26+
27+
rescue_from(ErrorB) do |*|
28+
raise GraphQL::ExecutionError, "boom!"
29+
end
30+
31+
rescue_from(ErrorC) do |err, *|
32+
err.value
33+
end
34+
35+
class Query < GraphQL::Schema::Object
36+
field :f1, Int, null: true do
37+
argument :a1, Int, required: false
38+
end
39+
40+
def f1(a1: nil)
41+
raise ErrorA, "f1 broke"
42+
end
43+
44+
field :f2, Int, null: true
45+
def f2
46+
GraphQL::Execution::Lazy.new { raise ErrorA, "f2 broke" }
47+
end
48+
49+
field :f3, Int, null: true
50+
51+
def f3
52+
raise ErrorB
53+
end
54+
55+
field :f4, Int, null: false
56+
def f4
57+
raise ErrorC.new(value: 20)
58+
end
59+
60+
field :f5, Int, null: true
61+
def f5
62+
raise ErrorASubclass, "raised subclass"
63+
end
64+
end
65+
66+
query(Query)
67+
end
68+
69+
describe "rescue_from handling" do
70+
it "can replace values with `nil`" do
71+
ctx = { errors: [] }
72+
res = ErrorsTestSchema.execute "{ f1(a1: 1) }", context: ctx, root_value: :abc
73+
assert_equal({ "data" => { "f1" => nil } }, res)
74+
assert_equal ["f1 broke (ErrorsTestSchema::Query.f1, :abc, {:a1=>1})"], ctx[:errors]
75+
end
76+
77+
it "rescues errors from lazy code" do
78+
ctx = { errors: [] }
79+
res = ErrorsTestSchema.execute("{ f2 }", context: ctx)
80+
assert_equal({ "data" => { "f2" => nil } }, res)
81+
assert_equal ["f2 broke (ErrorsTestSchema::Query.f2, nil, {})"], ctx[:errors]
82+
end
83+
84+
it "can raise new errors" do
85+
res = ErrorsTestSchema.execute("{ f3 }")
86+
expected_error = {
87+
"message"=>"boom!",
88+
"locations"=>[{"line"=>1, "column"=>3}],
89+
"path"=>["f3"]
90+
}
91+
assert_equal({ "data" => { "f3" => nil }, "errors" => [expected_error] }, res)
92+
end
93+
94+
it "can replace values with non-nil" do
95+
res = ErrorsTestSchema.execute("{ f4 }")
96+
assert_equal({ "data" => { "f4" => 20 } }, res)
97+
end
98+
99+
it "rescues subclasses" do
100+
context = { errors: [] }
101+
res = ErrorsTestSchema.execute("{ f5 }", context: context)
102+
assert_equal({ "data" => { "f5" => nil } }, res)
103+
assert_equal ["raised subclass (ErrorsTestSchema::Query.f5, nil, {})"], context[:errors]
104+
end
105+
end
106+
end

0 commit comments

Comments
 (0)