graph_weaver 0.0.1 → 0.2.0

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +279 -0
  4. data/Gemfile.lock +75 -17
  5. data/Makefile +11 -1
  6. data/NOTES.md +1 -1
  7. data/PLAN.md +85 -22
  8. data/README.md +86 -26
  9. data/docs/cassettes.md +76 -0
  10. data/docs/errors.md +134 -0
  11. data/docs/generated_modules.md +229 -0
  12. data/docs/getting_started.md +134 -0
  13. data/docs/logging.md +33 -0
  14. data/docs/real_world.md +53 -0
  15. data/docs/scalars.md +183 -0
  16. data/docs/testing.md +103 -0
  17. data/docs/transports.md +124 -0
  18. data/graph_weaver.gemspec +10 -1
  19. data/lib/graph_weaver/client.rb +200 -0
  20. data/lib/graph_weaver/codegen/emit.rb +286 -0
  21. data/lib/graph_weaver/codegen/enum_type.rb +149 -0
  22. data/lib/graph_weaver/codegen/nodes.rb +356 -0
  23. data/lib/graph_weaver/codegen/scalar_type.rb +256 -0
  24. data/lib/graph_weaver/codegen.rb +396 -401
  25. data/lib/graph_weaver/errors.rb +322 -0
  26. data/lib/graph_weaver/hints.rb +63 -0
  27. data/lib/graph_weaver/inflect.rb +19 -0
  28. data/lib/graph_weaver/logging.rb +41 -0
  29. data/lib/graph_weaver/railtie.rb +29 -0
  30. data/lib/graph_weaver/response.rb +55 -0
  31. data/lib/graph_weaver/retry.rb +97 -0
  32. data/lib/graph_weaver/rspec.rb +59 -0
  33. data/lib/graph_weaver/schema_loader.rb +208 -8
  34. data/lib/graph_weaver/selection.rb +68 -0
  35. data/lib/graph_weaver/tasks.rb +71 -0
  36. data/lib/graph_weaver/testing/cassette.rb +224 -0
  37. data/lib/graph_weaver/testing/failure.rb +109 -0
  38. data/lib/graph_weaver/testing/fake_client.rb +228 -0
  39. data/lib/graph_weaver/testing/values.rb +98 -0
  40. data/lib/graph_weaver/testing.rb +106 -0
  41. data/lib/graph_weaver/transport/faraday.rb +56 -0
  42. data/lib/graph_weaver/transport/http.rb +87 -0
  43. data/lib/graph_weaver/transport.rb +120 -0
  44. data/lib/graph_weaver/version.rb +1 -1
  45. data/lib/graph_weaver.rb +268 -4
  46. metadata +132 -2
  47. data/lib/graph_weaver/http_executor.rb +0 -31
@@ -0,0 +1,322 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require_relative "inflect"
6
+ require_relative "logging"
7
+
8
+ module GraphWeaver
9
+ # Base for every error GraphWeaver raises — rescue this to catch them
10
+ # all. #message is the human-friendly side; #to_h is the machine side —
11
+ # a JSON-ready Hash (string keys) for logging, agents, or surfacing
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.
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
+
25
+ def to_h
26
+ { "error" => self.class.name, "message" => message }
27
+ end
28
+ end
29
+
30
+ # The request never reached the server: connection refused, DNS failure,
31
+ # TLS handshake, timeout. The original exception is preserved as #cause.
32
+ # Generally retriable.
33
+ class TransportError < Error
34
+ def to_h
35
+ super.merge("cause" => cause&.class&.name)
36
+ end
37
+ end
38
+
39
+ class << self
40
+ # The exception classes the bundled transports reclassify as
41
+ # TransportError — network-level failures where the request never
42
+ # reached the server. A mutable Set: each transport contributes its own
43
+ # on load (net/http adds Timeout/SSL, Faraday adds its ConnectionFailed,
44
+ # …), and you can add more so they get the same handling:
45
+ #
46
+ # GraphWeaver.transport_errors << MyPool::TimeoutError
47
+ # GraphWeaver.register_transport_error(Adapter::ResetError)
48
+ #
49
+ # SystemCallError covers every Errno::* (connection refused/reset, host
50
+ # unreachable); SocketError covers DNS.
51
+ def transport_errors
52
+ @transport_errors ||= Set[SocketError, SystemCallError, IOError]
53
+ end
54
+
55
+ # Add one or more exception classes to the transport-error set.
56
+ def register_transport_error(*classes)
57
+ transport_errors.merge(classes)
58
+ classes
59
+ end
60
+ end
61
+
62
+ # The request reached the server but it returned a non-2xx status — a 500
63
+ # that exploded, a 502 from a proxy, a 401, etc. Distinct from a GraphQL
64
+ # error: we got an HTTP response, it just wasn't success.
65
+ class ServerError < Error
66
+ attr_reader :status, :body
67
+
68
+ def initialize(status:, body: nil)
69
+ @status = status
70
+ @body = body
71
+ snippet = body.to_s.empty? ? "" : ": #{body.to_s[0, 500]}"
72
+ super("HTTP #{status}#{snippet}")
73
+ end
74
+
75
+ def to_h
76
+ super.merge("status" => status)
77
+ end
78
+ end
79
+
80
+ # One entry from a GraphQL response's top-level `errors` array. A value
81
+ # object (not raised) — the response envelope and QueryError carry these.
82
+ # Match on #code (extensions["code"]) rather than the message string.
83
+ class GraphQLError
84
+ attr_reader :message, :locations, :path, :extensions
85
+
86
+ def initialize(message:, locations: [], path: nil, extensions: {}, type: nil)
87
+ @message = message
88
+ @locations = locations
89
+ @path = path
90
+ @extensions = extensions
91
+ @error_type = type
92
+ end
93
+
94
+ def self.from_h(hash)
95
+ new(
96
+ message: hash["message"] || "(no message)",
97
+ locations: hash["locations"] || [],
98
+ path: hash["path"],
99
+ extensions: hash["extensions"] || {},
100
+ type: hash["type"],
101
+ )
102
+ end
103
+
104
+ # The machine-readable error code, e.g. "THROTTLED" — the thing to
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.
108
+ def code
109
+ extensions["code"] || @error_type
110
+ end
111
+
112
+ # Message shapes servers use when they reject the *shape* of a query
113
+ # (unknown field/type/argument). Heuristic by necessity: only Apollo
114
+ # sets a standard code (GRAPHQL_VALIDATION_FAILED); graphql-ruby and
115
+ # GitHub speak in messages.
116
+ VALIDATION_MESSAGE = /doesn't exist|Cannot query field|Unknown (field|type|argument)|isn't defined|undefined (field|type)/i
117
+
118
+ # True when this error looks like the server rejected the query's
119
+ # shape — for a generated module that usually means the schema
120
+ # changed after generation.
121
+ def validation?
122
+ code == "GRAPHQL_VALIDATION_FAILED" || VALIDATION_MESSAGE.match?(message)
123
+ end
124
+
125
+ # The field the error points at, as a stable dotted path with list
126
+ # indices stripped — ["people", 3, "email"] => "people.email". The
127
+ # parseable key for grouping/reporting (the raw #path keeps indices).
128
+ # nil for global errors with no path.
129
+ def field
130
+ return unless path
131
+
132
+ named = path.reject { |seg| seg.is_a?(Integer) || seg.to_s.match?(/\A\d+\z/) }
133
+ named.join(".") unless named.empty?
134
+ end
135
+
136
+ def to_s
137
+ loc = locations&.first
138
+ at = loc ? " at #{loc["line"]}:#{loc["column"]}" : ""
139
+ where = path ? " (path: #{path.join(".")})" : ""
140
+ tag = code ? " [#{code}]" : ""
141
+ "#{message}#{at}#{where}#{tag}"
142
+ end
143
+
144
+ # JSON-ready: the problematic field (both forms — #field for grouping,
145
+ # #path with indices for exact location), the machine code, and the
146
+ # server's full extensions.
147
+ def to_h
148
+ {
149
+ "message" => message,
150
+ "code" => code,
151
+ "field" => field,
152
+ "path" => path,
153
+ "locations" => locations,
154
+ "extensions" => extensions,
155
+ "validation" => validation?,
156
+ }
157
+ end
158
+ end
159
+
160
+ # Shared filtering over a collection of GraphQLErrors, for surfacing
161
+ # field-level failures programmatically. Host must define #errors.
162
+ module ErrorFiltering
163
+ include Kernel # for sorbet: hosts are Objects
164
+
165
+ # the host's interface, overridden by its attr_readers
166
+ def errors
167
+ raise NotImplementedError, "#{self.class} must define #errors"
168
+ end
169
+
170
+ def data
171
+ raise NotImplementedError, "#{self.class} must define #data"
172
+ end
173
+
174
+ # Errors touching a field path — "user.email" or ["user", "email"];
175
+ # prefix match, so deeper errors count too. List indices appear as
176
+ # path segments ("people.0.email").
177
+ def errors_at(path)
178
+ want = (path.is_a?(String) ? path.split(".") : path).map(&:to_s)
179
+ errors.select { |error| error.path && error.path.map(&:to_s).first(want.size) == want }
180
+ end
181
+
182
+ # True when any error looks like the server rejected the query's
183
+ # shape — the schema has likely changed since the module was
184
+ # generated. Refresh the schema dump and regenerate (rake
185
+ # graph_weaver:schema:refresh && rake graph_weaver:generate).
186
+ def schema_stale?
187
+ errors.any?(&:validation?)
188
+ end
189
+
190
+ # Errors grouped by the field they point at (index-stripped dotted
191
+ # path; nil key for global errors) — iterate with each_error:
192
+ #
193
+ # response.each_error do |field, errors|
194
+ # form.add_error(field, errors.map(&:message))
195
+ # end
196
+ def errors_by_field
197
+ errors.group_by(&:field)
198
+ end
199
+
200
+ def each_error(&block)
201
+ errors_by_field.each(&block)
202
+ end
203
+
204
+ # The id of the record an error points into, resolved by walking the
205
+ # error's path through the (partial) typed data: an error at
206
+ # ["people", 3, "email"] resolves to people[3].id. nil when the data
207
+ # is missing, the path doesn't walk, or the record has no id field.
208
+ def entity_id(error)
209
+ # untyped by nature: the walk traverses whatever structs this query
210
+ # generated, reassigning across types at each step
211
+ node = T.let(data, T.untyped)
212
+ return unless node && error.path
213
+
214
+ error.path[0..-2].each do |segment|
215
+ node = if segment.is_a?(Integer) || segment.to_s.match?(/\A\d+\z/)
216
+ node.is_a?(Array) ? node[segment.to_i] : nil
217
+ else
218
+ prop = GraphWeaver::Inflect.underscore(segment.to_s).to_sym
219
+ node.respond_to?(prop) ? node.public_send(prop) : nil
220
+ end
221
+ return if node.nil?
222
+ end
223
+
224
+ node.respond_to?(:id) ? node.id : nil
225
+ end
226
+
227
+ # The user-facing rollup: errors keyed by field, with the ids of the
228
+ # actual records that failed inlined — "the 3 in people.3.email is
229
+ # useless to a user; people.email plus which people is the answer".
230
+ #
231
+ # { "people.email" => { "messages" => [...], "codes" => [...],
232
+ # "entity_ids" => ["7", "9"], "errors" => [full to_h...] } }
233
+ def report
234
+ errors_by_field.to_h do |field, field_errors|
235
+ [field, {
236
+ "messages" => field_errors.map(&:message),
237
+ "codes" => field_errors.filter_map(&:code).uniq,
238
+ "entity_ids" => field_errors.filter_map { |e| entity_id(e) }.uniq,
239
+ "errors" => field_errors.map(&:to_h),
240
+ }]
241
+ end
242
+ end
243
+ end
244
+
245
+ # Raised when a GraphQL response carried top-level errors and the caller
246
+ # demanded data (Response#data!, or the one-shot GraphWeaver.execute).
247
+ # Carries the structured errors, any partial data, and top-level
248
+ # extensions (cost/throttle metadata).
249
+ class QueryError < Error
250
+ include ErrorFiltering
251
+
252
+ attr_reader :errors, :data, :extensions
253
+
254
+ def initialize(errors, data: nil, extensions: {})
255
+ @errors = errors
256
+ @data = data
257
+ @extensions = extensions
258
+ super(summary)
259
+ end
260
+
261
+ # All non-nil error codes — handy for `codes.include?("THROTTLED")`.
262
+ def codes
263
+ errors.map(&:code).compact
264
+ end
265
+
266
+ # The machine side: every error with its path/code/extensions, plus
267
+ # the drift verdict — nest this straight into a JSON response.
268
+ def to_h
269
+ super.merge(
270
+ "schema_stale" => schema_stale?,
271
+ "codes" => codes,
272
+ "errors" => errors.map(&:to_h),
273
+ "extensions" => extensions,
274
+ )
275
+ end
276
+
277
+ private
278
+
279
+ def summary
280
+ first = errors.first
281
+ more = errors.size > 1 ? " (and #{errors.size - 1} more)" : ""
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)" : ""
283
+ "GraphQL query failed: #{first}#{more}#{drift}"
284
+ end
285
+ end
286
+
287
+ # Raised when a response can't be cast into the generated structs — the
288
+ # wire data disagreed with the types the schema promised at generation
289
+ # time (a nil where non-null was declared, a malformed scalar, an
290
+ # unknown enum value). #struct names the generated type that failed;
291
+ # #cause carries the original TypeError/KeyError with the offending
292
+ # prop in its message.
293
+ class TypeError < Error
294
+ attr_reader :struct
295
+
296
+ def initialize(struct:, error: nil, message: nil)
297
+ @struct = struct
298
+ super("failed to cast response into #{struct}: #{message || error&.message}")
299
+ end
300
+
301
+ def to_h
302
+ super.merge("struct" => struct.to_s, "cause" => cause&.message)
303
+ end
304
+ end
305
+
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
311
+ attr_reader :errors
312
+
313
+ def initialize(errors)
314
+ @errors = errors
315
+ super("invalid query: #{errors.map { |e| e[:message] }.join("; ")}")
316
+ end
317
+
318
+ def to_h
319
+ super.merge("errors" => errors.map { |e| e.transform_keys(&:to_s) })
320
+ end
321
+ end
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
@@ -0,0 +1,19 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module GraphWeaver
5
+ # GraphQL names are plain camelCase/SCREAMING_SNAKE — no acronym edge
6
+ # cases, so minimal inflection beats an activesupport dependency
7
+ module Inflect
8
+ module_function
9
+
10
+ def underscore(name)
11
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
12
+ end
13
+
14
+ def camelize(name)
15
+ # reject: leading underscores split into empty parts (_Service, _and)
16
+ name.split("_").reject(&:empty?).map { |part| "#{part[0].upcase}#{part[1..]}" }.join
17
+ end
18
+ end
19
+ 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,29 @@
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
+ GraphWeaver.load_generated! if Dir.exist?(GraphWeaver.generated_path)
28
+ end
29
+ end
@@ -0,0 +1,55 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require_relative "errors"
7
+
8
+ module GraphWeaver
9
+ # The envelope every generated #execute returns: the typed data (nil on a
10
+ # total failure), the top-level GraphQL errors, and top-level extensions
11
+ # (cost/throttle metadata). Generic over the query's Result type so
12
+ # response.data stays fully typed — the generated code instantiates it as
13
+ # GraphWeaver::Response[SomeQuery::Result]. #data! is the strict accessor:
14
+ # the result, or a raised QueryError.
15
+ class Response
16
+ extend T::Sig
17
+ extend T::Generic
18
+ include ErrorFiltering
19
+
20
+ Data = type_member
21
+
22
+ sig { returns(T.nilable(Data)) }
23
+ attr_reader :data
24
+
25
+ sig { returns(T::Array[GraphWeaver::GraphQLError]) }
26
+ attr_reader :errors
27
+
28
+ sig { returns(T::Hash[String, T.untyped]) }
29
+ attr_reader :extensions
30
+
31
+ sig do
32
+ params(
33
+ data: T.nilable(Data),
34
+ errors: T::Array[GraphWeaver::GraphQLError],
35
+ extensions: T::Hash[String, T.untyped],
36
+ ).void
37
+ end
38
+ def initialize(data:, errors: [], extensions: {})
39
+ @data = data
40
+ @errors = errors
41
+ @extensions = extensions
42
+ end
43
+
44
+ sig { returns(T::Boolean) }
45
+ def errors? = !errors.empty?
46
+
47
+ # The typed result, or raise QueryError if the response carried top-level
48
+ # errors (partial data and extensions ride along on the error).
49
+ sig { returns(Data) }
50
+ def data!
51
+ raise GraphWeaver::QueryError.new(errors, data: data, extensions: extensions) unless errors.empty?
52
+ T.must(data)
53
+ end
54
+ end
55
+ 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
@@ -0,0 +1,59 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "testing"
5
+
6
+ # RSpec integration — require from your spec helper instead of
7
+ # "graph_weaver/testing":
8
+ #
9
+ # require "graph_weaver/rspec"
10
+ #
11
+ # That one line is the whole setup in a conventional app: the schema
12
+ # auto-locates from the committed dump (GraphWeaver.schema_path) and
13
+ # auto_fake defaults on, so every example runs against a schema-correct
14
+ # fake. Configure to override:
15
+ #
16
+ # GraphWeaver::Testing.configure do |config|
17
+ # config.schema = MySchema # instead of the located dump
18
+ # config.auto_fake = false # opt out of per-example fakes
19
+ # end
20
+ #
21
+ # What it wires up:
22
+ # - seed: defaults to rspec's --seed, so `rspec --seed 1234` reproduces
23
+ # fake data along with test order
24
+ # - auto_fake: when on (and a schema resolves), each example gets a
25
+ # fresh seeded FakeClient installed as GraphWeaver.client —
26
+ # generated modules run in test mode with zero per-test setup. The
27
+ # prior client is restored after each example.
28
+ #
29
+ # note: modules generated with a baked-in client: constant don't consult
30
+ # GraphWeaver.client — generate without client: to make them fakeable.
31
+ module GraphWeaver
32
+ module Testing
33
+ module RSpecIntegration
34
+ def self.install(rspec_config)
35
+ rspec_config.before(:suite) do
36
+ config = GraphWeaver::Testing.config
37
+ config.seed ||= RSpec.configuration.seed
38
+ end
39
+
40
+ rspec_config.before(:each) do
41
+ config = GraphWeaver::Testing.config
42
+ next unless config.auto_fake && config.schema
43
+
44
+ @__graph_weaver_prior_client = GraphWeaver.client
45
+ GraphWeaver.client = FakeClient.new(schema: config.schema)
46
+ end
47
+
48
+ rspec_config.after(:each) do
49
+ config = GraphWeaver::Testing.config
50
+ next unless config.auto_fake && config.schema
51
+
52
+ GraphWeaver.client = @__graph_weaver_prior_client
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ RSpec.configure { |config| GraphWeaver::Testing::RSpecIntegration.install(config) } if defined?(RSpec)