graph_weaver 0.4.6 → 0.5.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1442 -0
  3. data/Gemfile.lock +23 -23
  4. data/README.md +115 -96
  5. data/docs/cassettes.md +93 -46
  6. data/docs/editors.md +82 -0
  7. data/docs/errors.md +34 -30
  8. data/docs/federation.md +521 -48
  9. data/docs/generated_modules.md +352 -137
  10. data/docs/getting_started.md +237 -67
  11. data/docs/logging.md +35 -6
  12. data/docs/real_world.md +21 -15
  13. data/docs/scalars.md +49 -154
  14. data/docs/testing.md +300 -52
  15. data/docs/transports.md +129 -30
  16. data/docs/upgrading.md +134 -0
  17. data/graph_weaver.gemspec +19 -3
  18. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  19. data/lib/graph_weaver/client.rb +118 -111
  20. data/lib/graph_weaver/codegen/aliases.rb +223 -0
  21. data/lib/graph_weaver/codegen/emit.rb +283 -261
  22. data/lib/graph_weaver/codegen/enum_type.rb +25 -124
  23. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  24. data/lib/graph_weaver/codegen/scalar_type.rb +69 -66
  25. data/lib/graph_weaver/codegen/type_helpers.rb +140 -0
  26. data/lib/graph_weaver/codegen.rb +672 -336
  27. data/lib/graph_weaver/errors.rb +154 -16
  28. data/lib/graph_weaver/federation.rb +259 -0
  29. data/lib/graph_weaver/hints.rb +9 -1
  30. data/lib/graph_weaver/in_process.rb +90 -0
  31. data/lib/graph_weaver/input_struct.rb +14 -2
  32. data/lib/graph_weaver/logging.rb +29 -0
  33. data/lib/graph_weaver/parsing.rb +59 -0
  34. data/lib/graph_weaver/query_module.rb +55 -0
  35. data/lib/graph_weaver/railtie.rb +23 -1
  36. data/lib/graph_weaver/representation.rb +74 -0
  37. data/lib/graph_weaver/response.rb +7 -0
  38. data/lib/graph_weaver/retry.rb +29 -8
  39. data/lib/graph_weaver/rspec.rb +220 -16
  40. data/lib/graph_weaver/schema_loader.rb +819 -60
  41. data/lib/graph_weaver/schemas.rb +48 -0
  42. data/lib/graph_weaver/selection.rb +43 -8
  43. data/lib/graph_weaver/tasks.rb +220 -22
  44. data/lib/graph_weaver/testing/cassette.rb +249 -81
  45. data/lib/graph_weaver/testing/coverage.rb +160 -0
  46. data/lib/graph_weaver/testing/failure.rb +14 -25
  47. data/lib/graph_weaver/testing/fake_client.rb +182 -22
  48. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  49. data/lib/graph_weaver/testing/router.rb +1452 -0
  50. data/lib/graph_weaver/testing/subgraphs.rb +134 -0
  51. data/lib/graph_weaver/testing.rb +209 -13
  52. data/lib/graph_weaver/transport/faraday.rb +28 -10
  53. data/lib/graph_weaver/transport/http.rb +99 -36
  54. data/lib/graph_weaver/transport.rb +67 -14
  55. data/lib/graph_weaver/version.rb +1 -1
  56. data/lib/graph_weaver.rb +416 -172
  57. metadata +25 -9
  58. data/CLAUDE.md +0 -69
  59. data/Makefile +0 -23
  60. data/NOTES.md +0 -182
  61. data/PLAN.md +0 -144
@@ -0,0 +1,134 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ require_relative "../schema_loader"
7
+ require_relative "../schemas"
8
+
9
+ module GraphWeaver
10
+ module Testing
11
+ # Which Ruby schema serves which subgraph — for the subgraphs this
12
+ # process serves at all.
13
+ #
14
+ # You can name them yourself, but the map is boilerplate you then have to
15
+ # keep right — so by default they're **derived from what each schema
16
+ # defines**. A schema serves subgraph `s` when it defines every type and
17
+ # field the routing table says `s` resolves. That's evidence, not a
18
+ # guess: matching on class names would be one (`Accounts::Schema`,
19
+ # `AccountsSchema`, `Subgraphs::Accounts`), and a wrong guess points a
20
+ # suite at the wrong resolvers and still passes.
21
+ #
22
+ # Two matches refuse, naming both — both fit the evidence, so picking
23
+ # either would be the guess this module exists to avoid. **No match is
24
+ # not a refusal**: a supergraph is routinely only partly local, the rest
25
+ # served by another process, so a subgraph nothing here defines is left
26
+ # out of the map. Only a query that reaches its fields fails, at plan
27
+ # time — see {Router}.
28
+ #
29
+ # `"reviews" => :fake` asks for schema-correct fabricated data instead of
30
+ # that refusal (see {FakeSubgraph}).
31
+ #
32
+ # The same check runs over a map you pass explicitly, which is how a
33
+ # swapped pair fails at construction rather than as a mystery three
34
+ # fetches later.
35
+ module Subgraphs
36
+ # how many coordinates a message names before it says "and N more"
37
+ SAMPLE = 5
38
+
39
+ # answer this subgraph with fabricated data rather than refusing
40
+ FAKE = :fake
41
+
42
+ class << self
43
+ # { "accounts" => Accounts::Schema, … } for the subgraphs this
44
+ # process serves — one nothing defines is absent, and left out.
45
+ # Names in `given` skip detection (:fake included); the rest are
46
+ # derived, and both go through the same check.
47
+ def resolve(table, given = nil, schemas: nil)
48
+ named = table.named_subgraphs(given)
49
+ searched = schemas || GraphWeaver::Schemas.loaded
50
+ table.subgraphs.filter_map do |name|
51
+ served = named.key?(name) ? check!(table, name, named[name]) : detect(table, name, searched)
52
+ [name, served] if served
53
+ end.to_h
54
+ end
55
+
56
+ # every loaded schema that defines what the table says `name` resolves
57
+ def candidates(table, name, schemas = GraphWeaver::Schemas.loaded)
58
+ schemas.select { |schema| missing(table, name, schema).empty? }
59
+ end
60
+
61
+ # The schema coordinates the supergraph says `name` resolves — "Type"
62
+ # for one it declares, "Type.field" for one it answers. This is the
63
+ # evidence a match is judged on.
64
+ def expected(table, name)
65
+ table.types.flat_map do |type_name|
66
+ next [] unless table.declared_in(type_name).include?(name)
67
+
68
+ fields = table.fields(type_name).select { |field| table.owners(type_name, field).include?(name) }
69
+ [type_name] + fields.map { |field| "#{type_name}.#{field}" }
70
+ end
71
+ end
72
+
73
+ # which of them `schema` doesn't define
74
+ def missing(table, name, schema)
75
+ expected(table, name).reject { |coordinate| GraphWeaver::Schemas.defines?(schema, coordinate) }
76
+ end
77
+
78
+ # Is this subgraph served in this process? Exactly one candidate is
79
+ # what that means: none is somebody else's service, and several is a
80
+ # question only the caller can answer, so neither is something a suite
81
+ # can run against. `detect` turns the several into a refusal at
82
+ # construction; a report counts it as not served here, which is the
83
+ # same verdict phrased for something that never raises.
84
+ def served?(table, name, schemas = GraphWeaver::Schemas.loaded)
85
+ candidates(table, name, schemas).one?
86
+ end
87
+
88
+ private
89
+
90
+ # The schema serving `name`, or nil when nothing here does — the
91
+ # subgraph is somebody else's, which is not an error until a query
92
+ # asks for it.
93
+ def detect(table, name, schemas)
94
+ found = candidates(table, name, schemas)
95
+ return found.first if found.one?
96
+ return if found.empty?
97
+
98
+ # by name: a dev reload leaves two class objects spelled the same,
99
+ # and naming one of them twice reads as a bug in the message
100
+ names = found.map(&:name).uniq.sort
101
+ raise GraphWeaver::ConfigurationError, "#{names.size} loaded schemas define everything the " \
102
+ "supergraph says #{name.inspect} resolves (#{names.join(", ")}) — pass subgraphs: naming " \
103
+ "the one you mean"
104
+ end
105
+
106
+ def check!(table, name, schema)
107
+ return FAKE if schema == FAKE
108
+
109
+ if schema.is_a?(Symbol)
110
+ raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is #{schema.inspect} — " \
111
+ "the only symbol an entry takes is #{FAKE.inspect}, which answers it with fabricated data"
112
+ end
113
+
114
+ verify!(table, name, schema)
115
+ end
116
+
117
+ def verify!(table, name, schema)
118
+ gaps = missing(table, name, schema)
119
+ return schema if gaps.empty?
120
+
121
+ raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
122
+ "#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
123
+ "says #{name} resolves them. Did two entries get swapped?"
124
+ end
125
+
126
+ def sample(list)
127
+ return list.join(", ") if list.size <= SAMPLE
128
+
129
+ "#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -12,10 +12,16 @@ rescue LoadError
12
12
  end
13
13
 
14
14
  # Opt-in test tooling: require "graph_weaver/testing" from your spec
15
- # helper (never from production code). Configure once, initializer-style:
15
+ # helper (never from production code). Nothing here needs configuring —
16
+ # what a mode runs against is derived (see CLIENT_MODES). Configure to
17
+ # override a derivation, or to tune fabricated values:
16
18
  #
17
19
  # GraphWeaver::Testing.configure do |config|
18
- # config.schema = MySchema # for auto_fake / cassettes
20
+ # config.schema = MySchema # overrides the derived schema
21
+ # config.router = { subgraphs: { "reviews" => :fake } } # or supergraph:,
22
+ # # when it isn't the dump
23
+ # config.context = { current_user: } # baseline GraphQL context
24
+ # config.default_mode = :fake # untagged examples (graph_weaver/rspec)
19
25
  # config.seed = 42 # reproducible fakes
20
26
  # config.mode = :faker # or :literal; nil = auto
21
27
  # config.overrides = { "Person.name" => "Daniel" }
@@ -30,16 +36,26 @@ end
30
36
  # nil — auto: :faker when the gem is loaded, else :literal
31
37
  #
32
38
  # rspec users: require "graph_weaver/rspec" instead — it hooks the suite
33
- # (seed from rspec, optional auto-faked client per example).
39
+ # (seed from rspec, a client per example from its `graphql:` tag).
34
40
  module GraphWeaver
35
41
  module Testing
36
42
  MODES = [:faker, :literal].freeze
37
43
 
44
+ # What an example can run against, named by the rspec tag that selects
45
+ # it — `it "…", graphql: :in_process` (see graph_weaver/rspec):
46
+ #
47
+ # :fake fabricated, schema-correct data; no resolvers run
48
+ # :in_process your resolvers, one live schema class, in-process
49
+ # :router your resolvers, across a federated graph
50
+ CLIENT_MODES = %i[fake in_process router].freeze
51
+
38
52
  class Config
39
- attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :auto_fake,
53
+ attr_accessor :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
40
54
  :record, :anonymize
55
+ # #schema is written plainly and read with a fallback (below), the way
56
+ # #mode, #router and #default_mode are read plainly and written with a check
41
57
  attr_writer :schema
42
- attr_reader :mode
58
+ attr_reader :mode, :router, :default_mode
43
59
 
44
60
  def initialize
45
61
  @overrides = {}
@@ -48,12 +64,22 @@ module GraphWeaver
48
64
  @null_chance = 0.0
49
65
  @mode = nil # auto
50
66
  @schema = nil
67
+ @located = nil # the committed dump, once located
68
+ # not under spec/fixtures: `fixtures :all` globs that path for
69
+ # `{**,*}/*.yml` and would try to load cassettes as ActiveRecord
70
+ # fixtures, a subdirectory included
51
71
  @cassette_dir = "spec/cassettes"
52
- # explicit opt-in: swapping every example onto a fake is too
53
- # surprising to be a default a little friction beats unexpected
54
- # behavior (the schema still auto-locates once you opt in)
55
- @auto_fake = false
56
- # GRAPHWEAVER_RECORD=1 rspec ... -> Cassette.use re-records
72
+ # what an example with no `graphql:` tag runs against. nil leaves
73
+ # GraphWeaver.client alone: swapping every example onto something
74
+ # else is too surprising to be a default.
75
+ @default_mode = nil
76
+ # the GraphQL context every :in_process / :router example starts
77
+ # from; graphql_context merges onto it
78
+ @context = {}
79
+ # Router arguments, when the composed supergraph isn't the
80
+ # conventional dump — { supergraph:, subgraphs: }, subgraphs optional
81
+ @router = nil
82
+ # GRAPHWEAVER_RECORD=1 rspec ... -> Testing.cassette re-records
57
83
  @record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
58
84
  # anonymize responses as they're recorded (needs config.schema)
59
85
  @anonymize = false
@@ -61,11 +87,19 @@ module GraphWeaver
61
87
 
62
88
  # the explicitly configured schema, else the conventional dump
63
89
  # (SchemaLoader.locate at GraphWeaver.schema_path) — nil when
64
- # neither exists, which quietly disables auto_fake
90
+ # neither exists
65
91
  def schema
66
- @schema ||= GraphWeaver::SchemaLoader.locate
92
+ # the dump memoizes separately: explicit_schema has to stay honest
93
+ # about whether anyone set one, since :in_process won't run a dump's
94
+ # resolver-less types as if they were the live class
95
+ @schema || (@located ||= GraphWeaver::SchemaLoader.locate)
67
96
  end
68
97
 
98
+ # What's been set, without falling back to the dump — so validating
99
+ # overrides at configure time doesn't force a schema load on a suite
100
+ # that never asks for one.
101
+ def explicit_schema = @schema
102
+
69
103
  def mode=(mode)
70
104
  unless mode.nil? || MODES.include?(mode)
71
105
  raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
@@ -73,6 +107,107 @@ module GraphWeaver
73
107
 
74
108
  @mode = mode
75
109
  end
110
+
111
+ def default_mode=(mode)
112
+ unless mode.nil? || CLIENT_MODES.include?(mode)
113
+ raise ArgumentError,
114
+ "default_mode: must be one of #{CLIENT_MODES.inspect} (or nil to leave " \
115
+ "GraphWeaver.client alone), got #{mode.inspect}"
116
+ end
117
+
118
+ @default_mode = mode
119
+ end
120
+
121
+ # Router arguments — both keys optional, and each answers a different
122
+ # question. `supergraph:` is for one derivation can't find; without it
123
+ # the conventional dump is used, when that dump is itself a supergraph.
124
+ # `subgraphs:` is for what derivation can't settle, or for `"reviews"
125
+ # => :fake`, which fabricates a subgraph this process doesn't serve —
126
+ # the commonest reason to configure a router at all, and no reason to
127
+ # have to restate where the supergraph is.
128
+ def router=(arguments)
129
+ unless arguments.nil? || arguments.is_a?(Hash)
130
+ raise ArgumentError, "router: must be the arguments to build one, e.g. " \
131
+ "{ supergraph: \"supergraph.graphql\" } or { subgraphs: { \"reviews\" => :fake } }, " \
132
+ "got #{arguments.inspect}"
133
+ end
134
+ if arguments&.key?(:context)
135
+ # the rspec hook resets the router's context from config.context
136
+ # every example, so one set here would silently never be read
137
+ raise ArgumentError, "router: context: is set as config.context — the baseline every " \
138
+ ":in_process and :router example starts from"
139
+ end
140
+ unknown = (arguments&.keys || []) - %i[supergraph subgraphs]
141
+ raise ArgumentError, "router: doesn't take #{unknown.join(", ")}" if unknown.any?
142
+
143
+ @router = arguments
144
+ @built_router = nil
145
+ end
146
+
147
+ # Built once: parsing the supergraph is setup, not per-example work.
148
+ # #context is settable, so an example that runs as someone else sets
149
+ # that rather than rebuilding — the rspec hook resets it each time.
150
+ def built_router
151
+ @built_router ||= Router.new(supergraph: supergraph!, subgraphs: @router && @router[:subgraphs])
152
+ end
153
+
154
+ # The composed supergraph :router plans against — named, or the
155
+ # conventional dump when that's what it is. A client can't supply
156
+ # one: its schema is the API schema a router serves, with the
157
+ # @join__* routing table stripped out.
158
+ def supergraph!
159
+ return @router[:supergraph] if @router&.key?(:supergraph)
160
+
161
+ path = GraphWeaver::SchemaLoader.locate_path
162
+ return path if path && supergraph?(path)
163
+
164
+ raise GraphWeaver::Error, ":router needs the composed supergraph SDL — a client's schema " \
165
+ "is the API schema the router serves, with the @join__* routing table stripped out, so " \
166
+ "the supergraph has to be named. #{path ? "#{path} carries no @join__* markers" : "Nothing on disk at #{GraphWeaver.schema_path}"}. " \
167
+ "Set GraphWeaver::Testing.config.router = { supergraph: \"supergraph.graphql\" }."
168
+ end
169
+
170
+ # The live schema class :in_process runs when the example didn't name
171
+ # one — config.schema if that is a class, else whatever the app's own
172
+ # client already runs in-process. Only a live class has resolvers, so
173
+ # there is nothing else to fall back to: a dump is type information.
174
+ def schema_class!
175
+ # explicit_schema, not schema: the latter falls back to the committed
176
+ # dump, which loads as an anonymous GraphQL::Schema subclass — runnable
177
+ # by every test that matters, and holding not one resolver.
178
+ runnable(explicit_schema) || GraphWeaver.live_schema ||
179
+ raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
180
+ "GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
181
+ "borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
182
+ "GraphWeaver::Testing.config.schema = MySchema for the whole suite. A federated app " \
183
+ "names the subgraph it means, per example; graphql: :router runs the graph stitched.")
184
+ end
185
+
186
+ # The schema everything else derives from: the one you set, else the
187
+ # committed dump, else the schema the app's client talks to.
188
+ def reference_schema!
189
+ found = schema || (GraphWeaver.client.schema if GraphWeaver.client.respond_to?(:schema))
190
+ return found if found
191
+
192
+ raise GraphWeaver::Error, "no schema to run against — GraphWeaver.client isn't set, " \
193
+ "there's no schema dump at #{GraphWeaver.schema_path}, and " \
194
+ "GraphWeaver::Testing.config.schema is unset. Set any one of them."
195
+ end
196
+
197
+ private
198
+
199
+ # config.schema doubles as the :in_process class when it is one — but a
200
+ # dump has no resolvers, so it can only ever be type information.
201
+ def runnable(schema)
202
+ schema if schema.is_a?(Class) && schema <= GraphQL::Schema
203
+ end
204
+
205
+ def supergraph?(source)
206
+ GraphWeaver::SchemaLoader.routing_table(source)
207
+ true
208
+ rescue GraphWeaver::Error
209
+ false
210
+ end
76
211
  end
77
212
 
78
213
  class << self
@@ -82,6 +217,17 @@ module GraphWeaver
82
217
 
83
218
  def configure
84
219
  yield config
220
+ # a typo'd override key pins nothing and the test still passes, so
221
+ # catch it here — while the block that set it is still on the stack
222
+ validate_overrides!(config.explicit_schema, config.overrides) if config.explicit_schema
223
+ config
224
+ end
225
+
226
+ # Override keys name schema coordinates: "Type.field", or a bare field
227
+ # name matching that field on any type. Anything else is a typo that
228
+ # would silently fabricate random data instead of pinning a value.
229
+ def validate_overrides!(schema, overrides)
230
+ overrides.each_key { |key| validate_override_key!(schema, key.to_s) }
85
231
  end
86
232
 
87
233
  # back to defaults — between tests, or to undo an experiment
@@ -94,7 +240,54 @@ module GraphWeaver
94
240
  def cassette_path(name)
95
241
  return name if name.include?("/") || name.end_with?(".yml", ".yaml")
96
242
 
97
- File.join(config.cassette_dir, "#{name}.yml")
243
+ File.join(cassette_dir, "#{name}.yml")
244
+ end
245
+
246
+ # The configured directory, against Rails.root when there is one — a rake
247
+ # task runs from wherever it runs from; the cassettes don't move.
248
+ # const_get rather than a bare Rails: sorbet can't resolve a constant the
249
+ # gem doesn't depend on.
250
+ def cassette_dir
251
+ dir = config.cassette_dir
252
+ root = (Object.const_get(:Rails).root if Object.const_defined?(:Rails))
253
+ root ? File.join(root.to_s, dir) : dir
254
+ rescue NoMethodError
255
+ dir # something else named Rails
256
+ end
257
+
258
+ private
259
+
260
+ def validate_override_key!(schema, key)
261
+ type_name, field_name = key.split(".", 2)
262
+ # introspection fields (__typename) are real but absent from #fields
263
+ return if (field_name || type_name).start_with?("__")
264
+
265
+ if field_name.nil?
266
+ known = field_names(schema)
267
+ return if known.include?(type_name)
268
+
269
+ bad_override!(key, "matches no field in this schema", known, type_name)
270
+ end
271
+
272
+ type = schema.get_type(type_name)
273
+ unless type.respond_to?(:fields)
274
+ bad_override!(key, "names no object type in this schema", schema.types.keys, type_name)
275
+ end
276
+ return if type.fields.key?(field_name)
277
+
278
+ bad_override!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
279
+ end
280
+
281
+ def bad_override!(key, problem, dictionary, term)
282
+ suggestion = GraphWeaver.did_you_mean(dictionary, term)
283
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
284
+ raise GraphWeaver::Error, "override key #{key.inspect} #{problem}#{hint}"
285
+ end
286
+
287
+ # Every output field name in the schema — walked only when a bare key
288
+ # asks for it.
289
+ def field_names(schema)
290
+ schema.types.each_value.flat_map { |type| type.respond_to?(:fields) ? type.fields.keys : [] }.uniq
98
291
  end
99
292
  end
100
293
  end
@@ -102,5 +295,8 @@ end
102
295
 
103
296
  require_relative "testing/values"
104
297
  require_relative "testing/fake_client"
298
+ require_relative "testing/fake_subgraph"
105
299
  require_relative "testing/failure"
106
300
  require_relative "testing/cassette"
301
+ require_relative "testing/router"
302
+ require_relative "testing/coverage"
@@ -29,34 +29,52 @@ module GraphWeaver
29
29
  ::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError
30
30
  )
31
31
 
32
- def initialize(url_or_connection, headers: {}, &block)
32
+ def initialize(url_or_connection, headers: {}, open_timeout: nil, read_timeout: nil, &block)
33
33
  @connection = case url_or_connection
34
34
  when ::Faraday::Connection
35
- # a prebuilt connection carries its own headers/middleware, so
36
- # headers:/block would be silently dropped — fail loudly instead
37
- unless headers.empty? && block.nil?
35
+ # a prebuilt connection carries its own headers/middleware/
36
+ # timeouts, so they'd be silently dropped — fail loudly instead
37
+ unless headers.empty? && block.nil? && open_timeout.nil? && read_timeout.nil?
38
38
  raise ArgumentError,
39
- "headers:/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
39
+ "headers:/timeouts/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
40
40
  end
41
41
 
42
42
  url_or_connection
43
43
  else
44
- # Faraday appends the default adapter when the block doesn't set one
45
- ::Faraday.new(url: url_or_connection, headers:, &block)
44
+ # Faraday appends the default adapter when the block doesn't set
45
+ # one. Our defaults go on the connection so ours is the
46
+ # User-Agent, not Faraday's stock one; caller headers still win.
47
+ # Timeouts default to Transport::HTTP's — Faraday would
48
+ # otherwise inherit net/http's 60s/60s.
49
+ ::Faraday.new(
50
+ url: url_or_connection,
51
+ headers: DEFAULT_HEADERS.merge(headers),
52
+ request: {
53
+ open_timeout: open_timeout || DEFAULT_OPEN_TIMEOUT,
54
+ read_timeout: read_timeout || DEFAULT_READ_TIMEOUT,
55
+ },
56
+ &block
57
+ )
46
58
  end
47
59
  @url = @connection.url_prefix.to_s
60
+
61
+ # which adapter got picked decides socket reuse — Faraday's
62
+ # default net_http one opens a connection per request. Naming it
63
+ # is the cheapest way to make that discoverable.
64
+ GraphWeaver.log(:info) { "faraday transport #{@url} (adapter: #{@connection.builder.adapter})" }
48
65
  end
49
66
 
50
67
  private
51
68
 
52
- sig { override.params(body: String).returns([Integer, T.untyped]) }
69
+ sig { override.params(body: String).returns(T::Array[T.untyped]) }
53
70
  def post(body)
54
71
  response = @connection.post do |request|
55
- request.headers["Content-Type"] = "application/json"
72
+ # a prebuilt connection owns its headers only fill the blanks
73
+ DEFAULT_HEADERS.each { |name, value| request.headers[name] ||= value }
56
74
  request.body = body
57
75
  end
58
76
 
59
- [response.status, response.body]
77
+ [response.status, response.body, response.headers.to_h.transform_keys(&:downcase)]
60
78
  end
61
79
  end
62
80
  end
@@ -13,74 +13,137 @@ module GraphWeaver
13
13
  #
14
14
  # GraphWeaver::Transport::HTTP.new(url, headers: { ... }, read_timeout: 10)
15
15
  #
16
- # Timeouts surface as TransportError (retriable). The connection is
17
- # persistent (keep-alive), serialized behind a mutex one socket per
18
- # transport, dropped on any failure so the next call starts fresh.
19
- # For real connection pooling and a middleware ecosystem, use
20
- # Transport::Faraday.
16
+ # Timeouts surface as TransportError (retriable). Connections are
17
+ # persistent (keep-alive) and pooled: up to pool_size: sockets, opened
18
+ # lazily, reused warmest-first, and dropped on any failure so the next
19
+ # call starts fresh. For a middleware ecosystem, use Transport::Faraday.
21
20
  class HTTP < Transport
22
21
  # net/http's own network-level failures (Errno/SocketError/IOError
23
22
  # are already seeded) — added to the shared, extensible
24
23
  # transport-error set.
25
24
  GraphWeaver.register_transport_error(Timeout::Error, OpenSSL::SSL::SSLError)
26
25
 
27
- def initialize(url, headers: {}, open_timeout: 10, read_timeout: 30, keep_alive_timeout: 2)
26
+ # How many requests this process can have in flight at once. Rails sizes
27
+ # its own connection pool from RAILS_MAX_THREADS and this is the same
28
+ # question, so it answers both. A fiber server (Falcon) sets no such
29
+ # ceiling of its own — pass pool_size: there.
30
+ def self.default_pool_size
31
+ threads = ENV["RAILS_MAX_THREADS"].to_i
32
+ threads.positive? ? threads : 5
33
+ end
34
+
35
+ def initialize(url, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT,
36
+ read_timeout: DEFAULT_READ_TIMEOUT, keep_alive_timeout: 2, pool_size: nil,
37
+ ca_file: nil, ca_path: nil, cert: nil, key: nil, verify_mode: nil)
38
+ pool_size ||= self.class.default_pool_size
39
+ raise ArgumentError, "pool_size: must be >= 1" unless pool_size >= 1
40
+
28
41
  @url = url
29
42
  @uri = URI(url)
30
43
  @headers = headers
31
44
  @open_timeout = open_timeout
32
45
  @read_timeout = read_timeout
33
46
  @keep_alive_timeout = keep_alive_timeout
34
- @mutex = Mutex.new
35
- @http = T.let(nil, T.nilable(Net::HTTP))
47
+
48
+ # TLS, forwarded verbatim to Net::HTTP.start: a private CA
49
+ # (ca_file:/ca_path:), a client certificate (cert:/key:), or a
50
+ # verify_mode: — so mTLS doesn't mean reaching for Faraday
51
+ @ssl = { ca_file:, ca_path:, cert:, key:, verify_mode: }.compact
52
+ if @ssl.any? && @uri.scheme != "https"
53
+ raise ArgumentError, "TLS options need an https url — got #{url}"
54
+ end
55
+
56
+ # One permit per allowed socket: holding a permit is the right to
57
+ # hold a connection, so at most pool_size requests are in flight
58
+ # and the rest queue rather than opening unbounded sockets.
59
+ @pool_size = pool_size
60
+ @permits = SizedQueue.new(pool_size)
61
+ pool_size.times { @permits.push(true) }
62
+
63
+ # live connections, LIFO — a warm socket beats opening a cold one,
64
+ # so a single-threaded caller keeps reusing the same one
65
+ @idle = []
66
+ @idle_lock = Mutex.new
36
67
  end
37
68
 
38
69
  private
39
70
 
40
- sig { override.params(body: String).returns([Integer, T.untyped]) }
71
+ sig { override.params(body: String).returns(T::Array[T.untyped]) }
41
72
  def post(body)
42
- request = Net::HTTP::Post.new(@uri, { "Content-Type" => "application/json" }.merge(@headers))
73
+ request = Net::HTTP::Post.new(@uri, DEFAULT_HEADERS.merge(@headers))
43
74
  request.body = body
44
75
 
45
- response = @mutex.synchronize do
46
- begin
47
- connection.request(request)
48
- rescue => e
49
- # socket state is unknown — drop it so the next call starts
50
- # fresh (retry policy belongs to Retry, not here)
51
- disconnect
52
- raise e
53
- end
54
- end
76
+ response = with_connection { |http| http.request(request) }
55
77
 
56
- [response.code.to_i, response.body]
78
+ # each_header yields downcased names with repeats already joined
79
+ [response.code.to_i, response.body, response.each_header.to_h]
57
80
  end
58
81
 
59
- # The persistent connection. net/http proactively reconnects when
60
- # idle past keep_alive_timeout, so a server-closed keep-alive
82
+ # Lease a connection for one round trip. The permit is held across
83
+ # the whole trip — opening the socket included — so pool_size really
84
+ # is the concurrency ceiling.
85
+ def with_connection
86
+ acquire_permit
87
+ http = nil
88
+
89
+ begin
90
+ http = @idle_lock.synchronize { @idle.pop } || connect
91
+ result = yield http
92
+ @idle_lock.synchronize { @idle.push(http) }
93
+ result
94
+ rescue Exception
95
+ # socket state is unknown — drop it, leaving the slot empty so
96
+ # the next call starts fresh (retry policy belongs to Retry).
97
+ # Exception, not StandardError: a fiber scheduler cancels with
98
+ # Async::Stop, which descends from Exception.
99
+ disconnect(http)
100
+ raise
101
+ ensure
102
+ @permits.push(true)
103
+ end
104
+ end
105
+
106
+ # A fresh persistent connection. net/http proactively reconnects
107
+ # when idle past keep_alive_timeout, so a server-closed keep-alive
61
108
  # socket doesn't produce spurious failures.
62
- def connection
63
- @http ||= begin
64
- GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
65
- Net::HTTP.start(
66
- @uri.hostname, @uri.port,
67
- use_ssl: @uri.scheme == "https",
68
- open_timeout: @open_timeout, read_timeout: @read_timeout,
69
- keep_alive_timeout: @keep_alive_timeout,
70
- )
109
+ def connect
110
+ GraphWeaver.log(:debug) { "connecting to #{@uri.hostname}:#{@uri.port}" }
111
+ Net::HTTP.start(
112
+ @uri.hostname, @uri.port,
113
+ use_ssl: @uri.scheme == "https",
114
+ open_timeout: @open_timeout, read_timeout: @read_timeout,
115
+ keep_alive_timeout: @keep_alive_timeout,
116
+ **@ssl,
117
+ )
118
+ end
119
+
120
+ # Take a permit, saying so when none is free. A queued request is
121
+ # indistinguishable from a slow server from the outside, which is the
122
+ # whole problem: pool_size is a hard ceiling under fibers exactly as
123
+ # under threads. Warned once — a saturated pool stays saturated, and a
124
+ # line per request would bury it.
125
+ def acquire_permit
126
+ @permits.pop(true)
127
+ rescue ThreadError
128
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
+ @permits.pop
130
+ waited = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
131
+
132
+ first = !@saturated
133
+ @saturated = true
134
+ GraphWeaver.log(first ? :warn : :debug) do
135
+ "connection pool saturated: waited #{waited}ms for 1 of #{@pool_size} connections to " \
136
+ "#{@uri.hostname} — raise pool_size: to this process's concurrency"
71
137
  end
72
138
  end
73
139
 
74
- def disconnect
75
- http = @http
140
+ def disconnect(http)
76
141
  return unless http
77
142
 
78
143
  GraphWeaver.log(:debug) { "dropping connection to #{@uri.hostname}:#{@uri.port}" }
79
144
  http.finish if http.started?
80
145
  rescue IOError
81
146
  # already closed
82
- ensure
83
- @http = nil
84
147
  end
85
148
  end
86
149
  end