Skip to content

Commit 41281d3

Browse files
authored
Merge pull request rmosolgo#5263 from rmosolgo/manual-analysis-timeout
Use a manual timeout implementation in Analysis to handle I/O gracefully
2 parents bc3ce23 + 62b2c77 commit 41281d3

File tree

4 files changed

+146
-53
lines changed

4 files changed

+146
-53
lines changed

lib/graphql/analysis.rb

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@
66
require "graphql/analysis/max_query_complexity"
77
require "graphql/analysis/query_depth"
88
require "graphql/analysis/max_query_depth"
9-
require "timeout"
10-
119
module GraphQL
1210
module Analysis
1311
AST = self
12+
13+
class TimeoutError < AnalysisError
14+
def initialize(...)
15+
super("Timeout on validation of query")
16+
end
17+
end
18+
1419
module_function
1520
# Analyze a multiplex, and all queries within.
1621
# Multiplex analyzers are ran for all queries, keeping state.
@@ -61,13 +66,11 @@ def analyze_query(query, analyzers, multiplex_analyzers: [])
6166
if !analyzers_to_run.empty?
6267
visitor = GraphQL::Analysis::Visitor.new(
6368
query: query,
64-
analyzers: analyzers_to_run
69+
analyzers: analyzers_to_run,
70+
timeout: query.validate_timeout_remaining,
6571
)
6672

67-
# `nil` or `0` causes no timeout
68-
Timeout::timeout(query.validate_timeout_remaining) do
69-
visitor.visit
70-
end
73+
visitor.visit
7174

7275
if !visitor.rescued_errors.empty?
7376
return visitor.rescued_errors
@@ -79,8 +82,8 @@ def analyze_query(query, analyzers, multiplex_analyzers: [])
7982
[]
8083
end
8184
end
82-
rescue Timeout::Error
83-
[GraphQL::AnalysisError.new("Timeout on validation of query")]
85+
rescue TimeoutError => err
86+
[err]
8487
rescue GraphQL::UnauthorizedError, GraphQL::ExecutionError
8588
# This error was raised during analysis and will be returned the client before execution
8689
[]

lib/graphql/analysis/visitor.rb

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ module Analysis
1010
#
1111
# @see {GraphQL::Analysis::Analyzer} AST Analyzers for queries
1212
class Visitor < GraphQL::Language::StaticVisitor
13-
def initialize(query:, analyzers:)
13+
def initialize(query:, analyzers:, timeout:)
1414
@analyzers = analyzers
1515
@path = []
1616
@object_types = []
@@ -24,6 +24,11 @@ def initialize(query:, analyzers:)
2424
@types = query.types
2525
@response_path = []
2626
@skip_stack = [false]
27+
@timeout_time = if timeout
28+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) + timeout
29+
else
30+
Float::INFINITY
31+
end
2732
super(query.selected_operation)
2833
end
2934

@@ -72,28 +77,25 @@ def response_path
7277
module_eval <<-RUBY, __FILE__, __LINE__
7378
def call_on_enter_#{node_type}(node, parent)
7479
@analyzers.each do |a|
75-
begin
76-
a.on_enter_#{node_type}(node, parent, self)
77-
rescue AnalysisError => err
78-
@rescued_errors << err
79-
end
80+
a.on_enter_#{node_type}(node, parent, self)
81+
rescue AnalysisError => err
82+
@rescued_errors << err
8083
end
8184
end
8285
8386
def call_on_leave_#{node_type}(node, parent)
8487
@analyzers.each do |a|
85-
begin
86-
a.on_leave_#{node_type}(node, parent, self)
87-
rescue AnalysisError => err
88-
@rescued_errors << err
89-
end
88+
a.on_leave_#{node_type}(node, parent, self)
89+
rescue AnalysisError => err
90+
@rescued_errors << err
9091
end
9192
end
9293
9394
RUBY
9495
end
9596

9697
def on_operation_definition(node, parent)
98+
check_timeout
9799
object_type = @schema.root_type_for_operation(node.operation_type)
98100
@object_types.push(object_type)
99101
@path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}")
@@ -104,31 +106,27 @@ def on_operation_definition(node, parent)
104106
@path.pop
105107
end
106108

107-
def on_fragment_definition(node, parent)
108-
on_fragment_with_type(node) do
109-
@path.push("fragment #{node.name}")
110-
@in_fragment_def = false
111-
call_on_enter_fragment_definition(node, parent)
112-
super
113-
@in_fragment_def = false
114-
call_on_leave_fragment_definition(node, parent)
115-
end
116-
end
117-
118109
def on_inline_fragment(node, parent)
119-
on_fragment_with_type(node) do
120-
@path.push("...#{node.type ? " on #{node.type.name}" : ""}")
121-
@skipping = @skip_stack.last || skip?(node)
122-
@skip_stack << @skipping
123-
124-
call_on_enter_inline_fragment(node, parent)
125-
super
126-
@skipping = @skip_stack.pop
127-
call_on_leave_inline_fragment(node, parent)
110+
check_timeout
111+
object_type = if node.type
112+
@types.type(node.type.name)
113+
else
114+
@object_types.last
128115
end
116+
@object_types.push(object_type)
117+
@path.push("...#{node.type ? " on #{node.type.name}" : ""}")
118+
@skipping = @skip_stack.last || skip?(node)
119+
@skip_stack << @skipping
120+
call_on_enter_inline_fragment(node, parent)
121+
super
122+
@skipping = @skip_stack.pop
123+
call_on_leave_inline_fragment(node, parent)
124+
@object_types.pop
125+
@path.pop
129126
end
130127

131128
def on_field(node, parent)
129+
check_timeout
132130
@response_path.push(node.alias || node.name)
133131
parent_type = @object_types.last
134132
# This could be nil if the previous field wasn't found:
@@ -156,6 +154,7 @@ def on_field(node, parent)
156154
end
157155

158156
def on_directive(node, parent)
157+
check_timeout
159158
directive_defn = @schema.directives[node.name]
160159
@directive_definitions.push(directive_defn)
161160
call_on_enter_directive(node, parent)
@@ -165,6 +164,7 @@ def on_directive(node, parent)
165164
end
166165

167166
def on_argument(node, parent)
167+
check_timeout
168168
argument_defn = if (arg = @argument_definitions.last)
169169
arg_type = arg.type.unwrap
170170
if arg_type.kind.input_object?
@@ -190,6 +190,7 @@ def on_argument(node, parent)
190190
end
191191

192192
def on_fragment_spread(node, parent)
193+
check_timeout
193194
@path.push("... #{node.name}")
194195
@skipping = @skip_stack.last || skip?(node)
195196
@skip_stack << @skipping
@@ -267,16 +268,10 @@ def skip?(ast_node)
267268
!dir.empty? && !GraphQL::Execution::DirectiveChecks.include?(dir, query)
268269
end
269270

270-
def on_fragment_with_type(node)
271-
object_type = if node.type
272-
@types.type(node.type.name)
273-
else
274-
@object_types.last
271+
def check_timeout
272+
if Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_second) > @timeout_time
273+
raise GraphQL::Analysis::TimeoutError
275274
end
276-
@object_types.push(object_type)
277-
yield(node)
278-
@object_types.pop
279-
@path.pop
280275
end
281276
end
282277
end

lib/graphql/schema.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -821,8 +821,8 @@ def subscription_execution_strategy(new_subscription_execution_strategy = nil, d
821821

822822
attr_writer :validate_timeout
823823

824-
def validate_timeout(new_validate_timeout = nil)
825-
if new_validate_timeout
824+
def validate_timeout(new_validate_timeout = NOT_CONFIGURED)
825+
if !NOT_CONFIGURED.equal?(new_validate_timeout)
826826
@validate_timeout = new_validate_timeout
827827
elsif defined?(@validate_timeout)
828828
@validate_timeout

spec/graphql/analysis_spec.rb

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,25 +577,69 @@ def article(id:)
577577
describe ".validate_timeout" do
578578
class AnalysisTimeoutSchema < GraphQL::Schema
579579
class SlowAnalyzer < GraphQL::Analysis::Analyzer
580+
def initialize(...)
581+
super
582+
if query.context[:initialize_sleep]
583+
sleep 0.6
584+
end
585+
end
586+
580587
def on_enter_field(node, parent, visitor)
588+
if node.name != "__typename"
589+
sleep 0.1
590+
end
591+
super
592+
end
593+
594+
def on_enter_directive(...)
595+
sleep 0.1
596+
super
597+
end
598+
599+
def on_enter_argument(...)
581600
sleep 0.1
582601
super
583602
end
584603

604+
def on_enter_inline_fragment(...)
605+
sleep 0.1
606+
super
607+
end
608+
609+
def on_enter_fragment_spread(node, _parent, _visitor)
610+
if !node.name.include?("NoSleep")
611+
sleep 0.1
612+
end
613+
super
614+
end
615+
585616
def result
586617
nil
587618
end
588619
end
589620

590621
class Query < GraphQL::Schema::Object
591-
field :f1, Int
622+
field :f1, Int do
623+
argument :a, String, required: false
624+
argument :b, String, required: false
625+
argument :c, String, required: false
626+
argument :d, String, required: false
627+
argument :e, String, required: false
628+
argument :f, String, required: false
629+
end
592630

593-
def f1
631+
def f1(...)
594632
context[:int] ||= 0
595633
context[:int] += 1
596634
end
597635
end
598636

637+
class Nothing < GraphQL::Schema::Directive
638+
locations(GraphQL::Schema::Directive::FIELD)
639+
repeatable(true)
640+
end
641+
directive(Nothing)
642+
599643
query(Query)
600644
query_analyzer(SlowAnalyzer)
601645
validate_timeout 0.5
@@ -608,5 +652,56 @@ def f1
608652
res2 = AnalysisTimeoutSchema.execute("{ f1: f1, f2: f1, f3: f1, f4: f1, f5: f1, f6: f1}")
609653
assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]}
610654
end
655+
656+
it "covers directives" do
657+
res = AnalysisTimeoutSchema.execute("{ f1 @nothing @nothing }")
658+
assert_equal({ "f1" => 1 }, res["data"])
659+
660+
res2 = AnalysisTimeoutSchema.execute("{ f1 @nothing @nothing @nothing @nothing @nothing }")
661+
assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]}
662+
end
663+
664+
it "covers arguments" do
665+
res = AnalysisTimeoutSchema.execute("{ f1(a: \"a\", b: \"b\")}")
666+
assert_equal({ "f1" => 1 }, res["data"])
667+
668+
res2 = AnalysisTimeoutSchema.execute('{ f1(a: "a", b: "b", c: "c", d: "d", e: "e", f: "f") }')
669+
assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]}
670+
end
671+
672+
it "covers inline fragments" do
673+
res = AnalysisTimeoutSchema.execute("{ ... { f1 } ... { f1 } }")
674+
assert_equal({ "f1" => 1 }, res["data"])
675+
676+
res2 = AnalysisTimeoutSchema.execute("{ ... { f1 } ... { f1 } ... { f1 } ... { f1 } ... { f1 } ... { f1 } }")
677+
assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]}
678+
end
679+
680+
it "covers operation definitions" do
681+
res = AnalysisTimeoutSchema.execute('query Q1 { __typename }', operation_name: "Q1")
682+
assert_equal({ "__typename" => "Query" }, res["data"])
683+
684+
res = AnalysisTimeoutSchema.execute('query Q1 { __typename }', operation_name: "Q1", context: { initialize_sleep: true })
685+
assert_equal({ "__typename" => "Query" }, res["data"])
686+
end
687+
688+
it "covers fragment spreads" do
689+
res = AnalysisTimeoutSchema.execute("{ ...F } fragment F on Query { f1 }")
690+
assert_equal({ "f1" => 1 }, res["data"])
691+
692+
res2 = AnalysisTimeoutSchema.execute('{ ...F ...F ...F ...F ...F ...F } fragment F on Query { f1 }')
693+
assert_equal ["Timeout on validation of query"], res2["errors"].map { |e| e["message"]}
694+
end
695+
696+
it "can be ignored" do
697+
no_timeout_schema = Class.new(AnalysisTimeoutSchema) do
698+
validate_timeout(nil)
699+
end
700+
res = no_timeout_schema.execute("{ f1 @nothing @nothing @nothing @nothing @nothing }")
701+
assert_equal({ "f1" => 1 }, res["data"])
702+
703+
res2 = no_timeout_schema.execute('{ ...F ...F ...F ...F ...F ...F } fragment F on Query { f1 }')
704+
assert_equal({ "f1" => 1 }, res2["data"])
705+
end
611706
end
612707
end

0 commit comments

Comments
 (0)