graph_weaver 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +256 -0
- data/Gemfile.lock +32 -2
- data/Makefile +7 -2
- data/NOTES.md +1 -1
- data/PLAN.md +41 -1
- data/README.md +56 -41
- data/docs/cassettes.md +76 -0
- data/docs/errors.md +37 -9
- data/docs/generated_modules.md +178 -29
- data/docs/getting_started.md +136 -0
- data/docs/logging.md +33 -0
- data/docs/real_world.md +27 -20
- data/docs/scalars.md +122 -8
- data/docs/testing.md +55 -38
- data/docs/transports.md +124 -0
- data/graph_weaver.gemspec +7 -1
- data/lib/graph_weaver/client.rb +201 -0
- data/lib/graph_weaver/codegen/emit.rb +315 -76
- data/lib/graph_weaver/codegen/enum_type.rb +149 -0
- data/lib/graph_weaver/codegen/nodes.rb +123 -68
- data/lib/graph_weaver/codegen/scalar_type.rb +53 -35
- data/lib/graph_weaver/codegen.rb +272 -101
- data/lib/graph_weaver/errors.rb +37 -25
- data/lib/graph_weaver/hints.rb +63 -0
- data/lib/graph_weaver/inflect.rb +2 -1
- data/lib/graph_weaver/input_struct.rb +59 -0
- data/lib/graph_weaver/logging.rb +41 -0
- data/lib/graph_weaver/railtie.rb +30 -0
- data/lib/graph_weaver/retry.rb +97 -0
- data/lib/graph_weaver/rspec.rb +17 -14
- data/lib/graph_weaver/schema_loader.rb +156 -20
- data/lib/graph_weaver/selection.rb +3 -3
- data/lib/graph_weaver/tasks.rb +71 -0
- data/lib/graph_weaver/testing/cassette.rb +42 -21
- data/lib/graph_weaver/testing/failure.rb +22 -22
- data/lib/graph_weaver/testing/{fake_executor.rb → fake_client.rb} +13 -13
- data/lib/graph_weaver/testing/values.rb +2 -2
- data/lib/graph_weaver/testing.rb +31 -15
- data/lib/graph_weaver/transport/faraday.rb +56 -0
- data/lib/graph_weaver/transport/http.rb +87 -0
- data/lib/graph_weaver/transport.rb +120 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +289 -38
- metadata +74 -5
- data/lib/graph_weaver/faraday_executor.rb +0 -61
- data/lib/graph_weaver/http_executor.rb +0 -44
- data/lib/graph_weaver/testing/rspec.rb +0 -5
data/lib/graph_weaver/errors.rb
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "set"
|
|
5
4
|
require "sorbet-runtime"
|
|
6
5
|
require_relative "inflect"
|
|
6
|
+
require_relative "logging"
|
|
7
7
|
|
|
8
8
|
module GraphWeaver
|
|
9
9
|
# Base for every error GraphWeaver raises — rescue this to catch them
|
|
10
10
|
# all. #message is the human-friendly side; #to_h is the machine side —
|
|
11
11
|
# a JSON-ready Hash (string keys) for logging, agents, or surfacing
|
|
12
|
-
# structured failures to users.
|
|
12
|
+
# structured failures to users. One subclass per failure site —
|
|
13
|
+
# {TransportError} (never reached the server), {ServerError} (non-2xx),
|
|
14
|
+
# {QueryError} (GraphQL-level errors), {TypeError} (response wouldn't
|
|
15
|
+
# cast), {ValidationError} (build time) — each merging its specifics
|
|
16
|
+
# into #to_h.
|
|
13
17
|
class Error < StandardError
|
|
18
|
+
# every GraphWeaver error surfaces on the logger too (see
|
|
19
|
+
# GraphWeaver.logger) — construction here means a raise
|
|
20
|
+
def initialize(*args)
|
|
21
|
+
super
|
|
22
|
+
GraphWeaver.log(:warn) { "#{self.class.name}: #{message}" }
|
|
23
|
+
end
|
|
24
|
+
|
|
14
25
|
def to_h
|
|
15
26
|
{ "error" => self.class.name, "message" => message }
|
|
16
27
|
end
|
|
@@ -26,14 +37,14 @@ module GraphWeaver
|
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
class << self
|
|
29
|
-
# The exception classes the bundled
|
|
40
|
+
# The exception classes the bundled transports reclassify as
|
|
30
41
|
# TransportError — network-level failures where the request never
|
|
31
42
|
# reached the server. A mutable Set: each transport contributes its own
|
|
32
43
|
# on load (net/http adds Timeout/SSL, Faraday adds its ConnectionFailed,
|
|
33
44
|
# …), and you can add more so they get the same handling:
|
|
34
45
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
46
|
+
# GraphWeaver.transport_errors << MyPool::TimeoutError
|
|
47
|
+
# GraphWeaver.register_transport_error(Adapter::ResetError)
|
|
37
48
|
#
|
|
38
49
|
# SystemCallError covers every Errno::* (connection refused/reset, host
|
|
39
50
|
# unreachable); SocketError covers DNS.
|
|
@@ -72,11 +83,12 @@ module GraphWeaver
|
|
|
72
83
|
class GraphQLError
|
|
73
84
|
attr_reader :message, :locations, :path, :extensions
|
|
74
85
|
|
|
75
|
-
def initialize(message:, locations: [], path: nil, extensions: {})
|
|
86
|
+
def initialize(message:, locations: [], path: nil, extensions: {}, type: nil)
|
|
76
87
|
@message = message
|
|
77
88
|
@locations = locations
|
|
78
89
|
@path = path
|
|
79
90
|
@extensions = extensions
|
|
91
|
+
@error_type = type
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
def self.from_h(hash)
|
|
@@ -85,13 +97,16 @@ module GraphWeaver
|
|
|
85
97
|
locations: hash["locations"] || [],
|
|
86
98
|
path: hash["path"],
|
|
87
99
|
extensions: hash["extensions"] || {},
|
|
100
|
+
type: hash["type"],
|
|
88
101
|
)
|
|
89
102
|
end
|
|
90
103
|
|
|
91
104
|
# The machine-readable error code, e.g. "THROTTLED" — the thing to
|
|
92
|
-
# branch on.
|
|
105
|
+
# branch on. Read from extensions.code (the Apollo/spec-adjacent
|
|
106
|
+
# convention) or a top-level "type" (GitHub's dialect: NOT_FOUND,
|
|
107
|
+
# FORBIDDEN). nil when the server sent neither.
|
|
93
108
|
def code
|
|
94
|
-
extensions["code"]
|
|
109
|
+
extensions["code"] || @error_type
|
|
95
110
|
end
|
|
96
111
|
|
|
97
112
|
# Message shapes servers use when they reject the *shape* of a query
|
|
@@ -166,8 +181,8 @@ module GraphWeaver
|
|
|
166
181
|
|
|
167
182
|
# True when any error looks like the server rejected the query's
|
|
168
183
|
# shape — the schema has likely changed since the module was
|
|
169
|
-
# generated.
|
|
170
|
-
#
|
|
184
|
+
# generated. Refresh the schema dump and regenerate (rake
|
|
185
|
+
# graph_weaver:schema:refresh && rake graph_weaver:generate).
|
|
171
186
|
def schema_stale?
|
|
172
187
|
errors.any?(&:validation?)
|
|
173
188
|
end
|
|
@@ -175,9 +190,9 @@ module GraphWeaver
|
|
|
175
190
|
# Errors grouped by the field they point at (index-stripped dotted
|
|
176
191
|
# path; nil key for global errors) — iterate with each_error:
|
|
177
192
|
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
193
|
+
# response.each_error do |field, errors|
|
|
194
|
+
# form.add_error(field, errors.map(&:message))
|
|
195
|
+
# end
|
|
181
196
|
def errors_by_field
|
|
182
197
|
errors.group_by(&:field)
|
|
183
198
|
end
|
|
@@ -213,8 +228,8 @@ module GraphWeaver
|
|
|
213
228
|
# actual records that failed inlined — "the 3 in people.3.email is
|
|
214
229
|
# useless to a user; people.email plus which people is the answer".
|
|
215
230
|
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
231
|
+
# { "people.email" => { "messages" => [...], "codes" => [...],
|
|
232
|
+
# "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
|
|
218
233
|
def report
|
|
219
234
|
errors_by_field.to_h do |field, field_errors|
|
|
220
235
|
[field, {
|
|
@@ -264,7 +279,7 @@ module GraphWeaver
|
|
|
264
279
|
def summary
|
|
265
280
|
first = errors.first
|
|
266
281
|
more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
|
|
267
|
-
drift = schema_stale? ? " — the server rejected the query shape: the schema may have changed since generation;
|
|
282
|
+
drift = schema_stale? ? " — the server rejected the query shape: the schema may have changed since generation; refresh the schema dump and regenerate (rake graph_weaver:schema:refresh && rake graph_weaver:generate)" : ""
|
|
268
283
|
"GraphQL query failed: #{first}#{more}#{drift}"
|
|
269
284
|
end
|
|
270
285
|
end
|
|
@@ -288,10 +303,11 @@ module GraphWeaver
|
|
|
288
303
|
end
|
|
289
304
|
end
|
|
290
305
|
|
|
291
|
-
# Build-time: the query didn't validate against the schema.
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
|
|
306
|
+
# Build-time: the query didn't validate against the schema. Carries the
|
|
307
|
+
# structured validation errors (message + line/column) rather than a
|
|
308
|
+
# joined string. Under the Error umbrella like everything else raised
|
|
309
|
+
# here (through 0.1.0 it was an ArgumentError instead).
|
|
310
|
+
class ValidationError < Error
|
|
295
311
|
attr_reader :errors
|
|
296
312
|
|
|
297
313
|
def initialize(errors)
|
|
@@ -300,11 +316,7 @@ module GraphWeaver
|
|
|
300
316
|
end
|
|
301
317
|
|
|
302
318
|
def to_h
|
|
303
|
-
{
|
|
304
|
-
"error" => self.class.name,
|
|
305
|
-
"message" => message,
|
|
306
|
-
"errors" => errors.map { |e| e.transform_keys(&:to_s) },
|
|
307
|
-
}
|
|
319
|
+
super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
|
|
308
320
|
end
|
|
309
321
|
end
|
|
310
322
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require_relative "inflect"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
# Included in generated response structs. GraphQL's camelCase fields
|
|
10
|
+
# become snake_case props, and reaching for the wire name is a classic
|
|
11
|
+
# stumble — result.nameWithOwner instead of result.name_with_owner.
|
|
12
|
+
# Catch the miss and point at the prop that does exist ("use ..." when
|
|
13
|
+
# the mapping is exact, "did you mean ...?" for a near-miss typo).
|
|
14
|
+
# (Typed call sites get this hint earlier, from srb tc.)
|
|
15
|
+
module Hints
|
|
16
|
+
include Kernel # for sorbet: hosts are Objects
|
|
17
|
+
|
|
18
|
+
# Guard for generated input-struct .coerce: a hash key that matches
|
|
19
|
+
# no prop raises (spellchecked) instead of silently dropping — a
|
|
20
|
+
# typo'd filter key must not become "match everything" on the wire.
|
|
21
|
+
def self.validate_keys!(struct, hash)
|
|
22
|
+
known = struct.props.keys.map(&:to_s)
|
|
23
|
+
unknown = hash.keys.map(&:to_s) - known
|
|
24
|
+
return if unknown.empty?
|
|
25
|
+
|
|
26
|
+
hints = unknown.map do |key|
|
|
27
|
+
prop = GraphWeaver::Inflect.underscore(key)
|
|
28
|
+
suggestion = if known.include?(prop)
|
|
29
|
+
prop # a wire-cased key — the exact snake_case prop exists
|
|
30
|
+
elsif defined?(DidYouMean::SpellChecker)
|
|
31
|
+
DidYouMean::SpellChecker.new(dictionary: known).correct(prop).first
|
|
32
|
+
end
|
|
33
|
+
suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "unknown key(s) for #{struct}: #{hints.join(", ")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def method_missing(name, *args, &block)
|
|
39
|
+
if args.empty? && (hint = prop_hint(name.to_s))
|
|
40
|
+
raise NoMethodError, "undefined method '#{name}' for #{self.class} — #{hint}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def prop_hint(name)
|
|
49
|
+
prop = GraphWeaver::Inflect.underscore(name)
|
|
50
|
+
if prop != name && respond_to?(prop)
|
|
51
|
+
return "GraphQL fields generate snake_case props; use '#{prop}'"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return unless defined?(DidYouMean::SpellChecker)
|
|
55
|
+
|
|
56
|
+
# a guess, not a mapping — spellcheck the (underscored) miss
|
|
57
|
+
# against the props that exist, so typos in either casing land
|
|
58
|
+
props = T.unsafe(self.class).props.keys.map(&:to_s)
|
|
59
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: props).correct(prop).first
|
|
60
|
+
"did you mean '#{suggestion}'?" if suggestion
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/graph_weaver/inflect.rb
CHANGED
|
@@ -12,7 +12,8 @@ module GraphWeaver
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def camelize(name)
|
|
15
|
-
|
|
15
|
+
# reject: leading underscores split into empty parts (_Service, _and)
|
|
16
|
+
name.split("_").reject(&:empty?).map { |part| "#{part[0].upcase}#{part[1..]}" }.join
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require_relative "hints"
|
|
7
|
+
|
|
8
|
+
module GraphWeaver
|
|
9
|
+
# Runtime for generated input structs. Each struct declares its typed
|
|
10
|
+
# consts plus a compact FIELDS table — (prop, wire name, requiredness,
|
|
11
|
+
# serializer, coercer) per field, with the conversions emitted as
|
|
12
|
+
# lambdas — and this module is the loop that drives it. One copy here
|
|
13
|
+
# instead of unrolled methods in every struct, which is the difference
|
|
14
|
+
# between ~2 lines and ~6 lines per field when a Hasura bool_exp pulls
|
|
15
|
+
# hundreds of input types into one module.
|
|
16
|
+
module InputStruct
|
|
17
|
+
include Kernel # for sorbet: hosts are T::Structs
|
|
18
|
+
|
|
19
|
+
# serializer/coercer are code-as-data from the generated file; nil
|
|
20
|
+
# means identity (the wire value passes through untouched)
|
|
21
|
+
Field = Struct.new(:prop, :wire, :required, :serializer, :coercer)
|
|
22
|
+
|
|
23
|
+
def self.included(base)
|
|
24
|
+
base.extend(ClassMethods)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# the wire hash — optional fields left nil stay off the wire
|
|
28
|
+
def serialize
|
|
29
|
+
self.class.const_get(:FIELDS).each_with_object({}) do |field, wire|
|
|
30
|
+
value = public_send(field.prop)
|
|
31
|
+
next if value.nil? && !field.required
|
|
32
|
+
|
|
33
|
+
wire[field.wire] = field.serializer && !value.nil? ? field.serializer.call(value) : value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
alias_method :to_h, :serialize
|
|
37
|
+
|
|
38
|
+
module ClassMethods
|
|
39
|
+
include Kernel
|
|
40
|
+
|
|
41
|
+
# Build from a plain hash (underscored keys, Symbol or String):
|
|
42
|
+
# enums accept their wire values, nested inputs accept hashes; the
|
|
43
|
+
# struct's types are enforced on construction, and unknown keys
|
|
44
|
+
# raise with a spellchecked hint.
|
|
45
|
+
def coerce(value)
|
|
46
|
+
return value if value.is_a?(self)
|
|
47
|
+
|
|
48
|
+
# a typo'd key must not silently drop off the wire
|
|
49
|
+
GraphWeaver::Hints.validate_keys!(self, value)
|
|
50
|
+
|
|
51
|
+
fields = T.unsafe(self).const_get(:FIELDS)
|
|
52
|
+
T.unsafe(self).new(**fields.to_h do |field|
|
|
53
|
+
raw = value.key?(field.prop) ? value[field.prop] : value[field.prop.to_s]
|
|
54
|
+
[field.prop, raw.nil? || field.coercer.nil? ? raw : field.coercer.call(raw)]
|
|
55
|
+
end)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
class << self
|
|
6
|
+
# Where GraphWeaver narrates what it's doing — anything
|
|
7
|
+
# stdlib-Logger-compatible (Logger, Rails.logger, semantic_logger...).
|
|
8
|
+
# Silent by default; Rails apps get Rails.logger wired by the railtie.
|
|
9
|
+
#
|
|
10
|
+
# GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)
|
|
11
|
+
#
|
|
12
|
+
# What logs where:
|
|
13
|
+
# debug — full queries + variables on the wire, responses
|
|
14
|
+
# (status/bytes/ms), connection lifecycle, parsed modules
|
|
15
|
+
# info — schema introspection and cache decisions, generated files
|
|
16
|
+
# written, query modules loaded
|
|
17
|
+
# warn — every GraphWeaver error raised
|
|
18
|
+
#
|
|
19
|
+
# Queries, variables, and responses appear at debug ONLY — they can
|
|
20
|
+
# carry PII. Auth headers never log.
|
|
21
|
+
attr_accessor :logger
|
|
22
|
+
|
|
23
|
+
# Internal: level-gated and lazy — the block only runs when a logger
|
|
24
|
+
# is listening. Messages carry "graph_weaver" as progname.
|
|
25
|
+
def log(level, &block)
|
|
26
|
+
logger&.public_send(level, "graph_weaver", &block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Internal: run the block, logging "<label> (Nms)" at level — timing
|
|
30
|
+
# skipped entirely when no logger is set. Returns the block's value.
|
|
31
|
+
def log_timed(level, label)
|
|
32
|
+
return yield unless logger
|
|
33
|
+
|
|
34
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
result = yield
|
|
36
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
37
|
+
log(level) { "#{label} (#{ms}ms)" }
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# typed: ignore — Rails::Railtie DSL
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Rails wiring, so the conventional layout needs no ceremony:
|
|
5
|
+
#
|
|
6
|
+
# - rake tasks: Rails.application.load_tasks collects every Railtie's
|
|
7
|
+
# rake_tasks block, so graph_weaver:* tasks appear with no Rakefile
|
|
8
|
+
# edit. (Outside Rails there is no task-discovery hook — add
|
|
9
|
+
# `require "graph_weaver/tasks"` to your Rakefile.)
|
|
10
|
+
# - generated modules: required at boot when generated_path exists,
|
|
11
|
+
# after config/initializers (registrations and GraphWeaver.client=
|
|
12
|
+
# run first — block-built type helpers must exist before the files
|
|
13
|
+
# that include them load). load_generated! stays idempotent, so
|
|
14
|
+
# calling it yourself too is harmless.
|
|
15
|
+
class GraphWeaver::Railtie < Rails::Railtie
|
|
16
|
+
rake_tasks do
|
|
17
|
+
require "graph_weaver/tasks"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rails.logger, unless the app already chose one (set
|
|
21
|
+
# GraphWeaver.logger = nil in an initializer to silence)
|
|
22
|
+
initializer "graph_weaver.logger" do
|
|
23
|
+
GraphWeaver.logger = Rails.logger if GraphWeaver.logger.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
initializer "graph_weaver.load_generated", after: :load_config_initializers do
|
|
27
|
+
# entries may be globs, so Dir[] rather than Dir.exist?
|
|
28
|
+
GraphWeaver.load_generated! if GraphWeaver.generated_paths.any? { |path| Dir[path].any? }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
# Wraps any client/transport with configurable retries — it satisfies
|
|
7
|
+
# the same execute contract, so it layers over HTTP, Faraday, or
|
|
8
|
+
# anything else:
|
|
9
|
+
#
|
|
10
|
+
# client = GraphWeaver::Retry.new(
|
|
11
|
+
# GraphWeaver::Transport::HTTP.new(url),
|
|
12
|
+
# tries: 5, # total attempts, first included
|
|
13
|
+
# on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
14
|
+
# backoff: :exponential, # or :linear, or ->(attempt) { seconds }
|
|
15
|
+
# base: 0.5, max: 30, # seconds; delays clamp at max:
|
|
16
|
+
# jitter: true, # randomize each delay by 50-100%
|
|
17
|
+
# retry_codes: ["THROTTLED"], # also retry GraphQL errors by code
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# What retries, by default:
|
|
21
|
+
# - TransportError: always (the request never arrived)
|
|
22
|
+
# - ServerError: only 5xx — a 4xx is a bug in the request, retrying
|
|
23
|
+
# won't fix it. Override with retry_if: ->(error) { ... }
|
|
24
|
+
# - responses whose GraphQL error codes intersect retry_codes: (off by
|
|
25
|
+
# default — pass the codes your API uses for transient failures)
|
|
26
|
+
#
|
|
27
|
+
# Exhausting tries re-raises the last error (or returns the last
|
|
28
|
+
# code-matched response).
|
|
29
|
+
class GraphWeaver::Retry
|
|
30
|
+
BACKOFFS = {
|
|
31
|
+
exponential: ->(base, attempt) { base * (2**(attempt - 1)) },
|
|
32
|
+
linear: ->(base, attempt) { base * attempt },
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# retry 5xx, not 4xx; everything else listed in on: retries
|
|
36
|
+
DEFAULT_RETRY_IF = lambda do |error|
|
|
37
|
+
!error.is_a?(GraphWeaver::ServerError) || error.status >= 500
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(client, tries: 3, on: [GraphWeaver::TransportError, GraphWeaver::ServerError],
|
|
41
|
+
backoff: :exponential, base: 0.5, max: 30, jitter: true, retry_if: DEFAULT_RETRY_IF,
|
|
42
|
+
retry_codes: [], sleeper: nil)
|
|
43
|
+
raise ArgumentError, "tries: must be >= 1" unless tries >= 1
|
|
44
|
+
|
|
45
|
+
@client = client
|
|
46
|
+
@tries = tries
|
|
47
|
+
@on = on
|
|
48
|
+
@backoff = if backoff.is_a?(Proc)
|
|
49
|
+
->(_base, attempt) { backoff.call(attempt) } # custom: ->(attempt) { seconds }
|
|
50
|
+
else
|
|
51
|
+
BACKOFFS.fetch(backoff) {
|
|
52
|
+
raise ArgumentError, "backoff: must be :exponential, :linear, or a Proc, got #{backoff.inspect}"
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
@base = base
|
|
56
|
+
@max = max
|
|
57
|
+
@jitter = jitter
|
|
58
|
+
@retry_if = retry_if
|
|
59
|
+
@retry_codes = retry_codes
|
|
60
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# surface the wrapped transport's endpoint (schema-dump provenance)
|
|
64
|
+
def url
|
|
65
|
+
@client.url if @client.respond_to?(:url)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(query, variables: {})
|
|
69
|
+
attempt = 0
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
attempt += 1
|
|
73
|
+
begin
|
|
74
|
+
response = @client.execute(query, variables:)
|
|
75
|
+
return response unless attempt < @tries && retryable_response?(response)
|
|
76
|
+
rescue *@on => e
|
|
77
|
+
raise if attempt >= @tries || !@retry_if.call(e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@sleeper.call(delay(attempt))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def retryable_response?(response)
|
|
87
|
+
return false if @retry_codes.empty?
|
|
88
|
+
|
|
89
|
+
codes = (response.to_h["errors"] || []).filter_map { |error| error.dig("extensions", "code") }
|
|
90
|
+
codes.intersect?(@retry_codes)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delay(attempt)
|
|
94
|
+
seconds = [@backoff.call(@base, attempt), @max].min.to_f
|
|
95
|
+
@jitter ? seconds * (0.5 + rand * 0.5) : seconds
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/graph_weaver/rspec.rb
CHANGED
|
@@ -6,23 +6,26 @@ require_relative "testing"
|
|
|
6
6
|
# RSpec integration — require from your spec helper instead of
|
|
7
7
|
# "graph_weaver/testing":
|
|
8
8
|
#
|
|
9
|
-
#
|
|
9
|
+
# require "graph_weaver/rspec"
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# Then opt in to per-example fakes (explicit on purpose — silently
|
|
12
|
+
# swapping every example onto a fake is too surprising to be a default):
|
|
13
|
+
#
|
|
14
|
+
# GraphWeaver::Testing.configure do |config|
|
|
15
|
+
# config.auto_fake = true # every example runs against a fake
|
|
16
|
+
# # config.schema = MySchema # optional: the committed dump auto-locates
|
|
17
|
+
# end
|
|
15
18
|
#
|
|
16
19
|
# What it wires up:
|
|
17
20
|
# - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
|
|
18
21
|
# fake data along with test order
|
|
19
|
-
# - auto_fake: when on (and schema
|
|
20
|
-
# seeded
|
|
21
|
-
# modules run in test mode with zero per-test setup. The
|
|
22
|
-
#
|
|
22
|
+
# - auto_fake: when on (and a schema resolves), each example gets a
|
|
23
|
+
# fresh seeded FakeClient installed as GraphWeaver.client —
|
|
24
|
+
# generated modules run in test mode with zero per-test setup. The
|
|
25
|
+
# prior client is restored after each example.
|
|
23
26
|
#
|
|
24
|
-
# note: modules generated with a baked-in
|
|
25
|
-
# GraphWeaver.
|
|
27
|
+
# note: modules generated with a baked-in client: constant don't consult
|
|
28
|
+
# GraphWeaver.client — generate without client: to make them fakeable.
|
|
26
29
|
module GraphWeaver
|
|
27
30
|
module Testing
|
|
28
31
|
module RSpecIntegration
|
|
@@ -36,15 +39,15 @@ module GraphWeaver
|
|
|
36
39
|
config = GraphWeaver::Testing.config
|
|
37
40
|
next unless config.auto_fake && config.schema
|
|
38
41
|
|
|
39
|
-
@
|
|
40
|
-
GraphWeaver.
|
|
42
|
+
@__graph_weaver_prior_client = GraphWeaver.client
|
|
43
|
+
GraphWeaver.client = FakeClient.new(schema: config.schema)
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
rspec_config.after(:each) do
|
|
44
47
|
config = GraphWeaver::Testing.config
|
|
45
48
|
next unless config.auto_fake && config.schema
|
|
46
49
|
|
|
47
|
-
GraphWeaver.
|
|
50
|
+
GraphWeaver.client = @__graph_weaver_prior_client
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
end
|