graph_weaver 0.4.4 → 0.5.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1357 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -136
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -251
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -98
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +72 -67
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +617 -264
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +12 -6
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +21 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +15 -1
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +820 -57
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +59 -7
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +186 -62
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +194 -28
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +31 -6
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +74 -18
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +398 -170
  62. metadata +20 -3
@@ -0,0 +1,130 @@
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 = (given || {}).to_h { |name, schema| [name.to_s, schema] }
49
+ unknown = named.keys - table.subgraphs
50
+ if unknown.any?
51
+ raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
52
+ "this supergraph doesn't have (its subgraphs are #{table.subgraphs.join(", ")})"
53
+ end
54
+
55
+ searched = schemas || GraphWeaver::Schemas.loaded
56
+ table.subgraphs.filter_map do |name|
57
+ served = named.key?(name) ? check!(table, name, named[name]) : detect(table, name, searched)
58
+ [name, served] if served
59
+ end.to_h
60
+ end
61
+
62
+ # every loaded schema that defines what the table says `name` resolves
63
+ def candidates(table, name, schemas = GraphWeaver::Schemas.loaded)
64
+ schemas.select { |schema| missing(table, name, schema).empty? }
65
+ end
66
+
67
+ # The schema coordinates the supergraph says `name` resolves — "Type"
68
+ # for one it declares, "Type.field" for one it answers. This is the
69
+ # evidence a match is judged on.
70
+ def expected(table, name)
71
+ table.types.flat_map do |type_name|
72
+ next [] unless table.declared_in(type_name).include?(name)
73
+
74
+ fields = table.fields(type_name).select { |field| table.owners(type_name, field).include?(name) }
75
+ [type_name] + fields.map { |field| "#{type_name}.#{field}" }
76
+ end
77
+ end
78
+
79
+ # which of them `schema` doesn't define
80
+ def missing(table, name, schema)
81
+ expected(table, name).reject { |coordinate| GraphWeaver::Schemas.defines?(schema, coordinate) }
82
+ end
83
+
84
+ private
85
+
86
+ # The schema serving `name`, or nil when nothing here does — the
87
+ # subgraph is somebody else's, which is not an error until a query
88
+ # asks for it.
89
+ def detect(table, name, schemas)
90
+ found = candidates(table, name, schemas)
91
+ return found.first if found.one?
92
+ return if found.empty?
93
+
94
+ # by name: a dev reload leaves two class objects spelled the same,
95
+ # and naming one of them twice reads as a bug in the message
96
+ names = found.map(&:name).uniq.sort
97
+ raise GraphWeaver::ConfigurationError, "#{names.size} loaded schemas define everything the " \
98
+ "supergraph says #{name.inspect} resolves (#{names.join(", ")}) — pass subgraphs: naming " \
99
+ "the one you mean"
100
+ end
101
+
102
+ def check!(table, name, schema)
103
+ return FAKE if schema == FAKE
104
+
105
+ if schema.is_a?(Symbol)
106
+ raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is #{schema.inspect} — " \
107
+ "the only symbol an entry takes is #{FAKE.inspect}, which answers it with fabricated data"
108
+ end
109
+
110
+ verify!(table, name, schema)
111
+ end
112
+
113
+ def verify!(table, name, schema)
114
+ gaps = missing(table, name, schema)
115
+ return schema if gaps.empty?
116
+
117
+ raise GraphWeaver::ConfigurationError, "subgraphs[#{name.inspect}] is " \
118
+ "#{schema.name || schema.inspect}, which doesn't define #{sample(gaps)} — the supergraph " \
119
+ "says #{name} resolves them. Did two entries get swapped?"
120
+ end
121
+
122
+ def sample(list)
123
+ return list.join(", ") if list.size <= SAMPLE
124
+
125
+ "#{list.first(SAMPLE).join(", ")} and #{list.size - SAMPLE} more"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ 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,23 @@ 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 :schema, :overrides, :seed, :list_size, :null_chance, :cassette_dir, :context,
40
54
  :record, :anonymize
41
- attr_writer :schema
42
- attr_reader :mode
55
+ attr_reader :mode, :router, :default_mode
43
56
 
44
57
  def initialize
45
58
  @overrides = {}
@@ -48,12 +61,22 @@ module GraphWeaver
48
61
  @null_chance = 0.0
49
62
  @mode = nil # auto
50
63
  @schema = nil
64
+ @located = nil # the committed dump, once located
65
+ # not under spec/fixtures: `fixtures :all` globs that path for
66
+ # `{**,*}/*.yml` and would try to load cassettes as ActiveRecord
67
+ # fixtures, a subdirectory included
51
68
  @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
69
+ # what an example with no `graphql:` tag runs against. nil leaves
70
+ # GraphWeaver.client alone: swapping every example onto something
71
+ # else is too surprising to be a default.
72
+ @default_mode = nil
73
+ # the GraphQL context every :in_process / :router example starts
74
+ # from; graphql_context merges onto it
75
+ @context = {}
76
+ # Router arguments, when the composed supergraph isn't the
77
+ # conventional dump — { supergraph:, subgraphs: }, subgraphs optional
78
+ @router = nil
79
+ # GRAPHWEAVER_RECORD=1 rspec ... -> Testing.cassette re-records
57
80
  @record = !ENV["GRAPHWEAVER_RECORD"].to_s.empty?
58
81
  # anonymize responses as they're recorded (needs config.schema)
59
82
  @anonymize = false
@@ -61,11 +84,19 @@ module GraphWeaver
61
84
 
62
85
  # the explicitly configured schema, else the conventional dump
63
86
  # (SchemaLoader.locate at GraphWeaver.schema_path) — nil when
64
- # neither exists, which quietly disables auto_fake
87
+ # neither exists
65
88
  def schema
66
- @schema ||= GraphWeaver::SchemaLoader.locate
89
+ # the dump memoizes separately: explicit_schema has to stay honest
90
+ # about whether anyone set one, since :in_process won't run a dump's
91
+ # resolver-less types as if they were the live class
92
+ @schema || (@located ||= GraphWeaver::SchemaLoader.locate)
67
93
  end
68
94
 
95
+ # What's been set, without falling back to the dump — so validating
96
+ # overrides at configure time doesn't force a schema load on a suite
97
+ # that never asks for one.
98
+ def explicit_schema = @schema
99
+
69
100
  def mode=(mode)
70
101
  unless mode.nil? || MODES.include?(mode)
71
102
  raise ArgumentError, "mode: must be one of #{MODES.inspect} (or nil for auto), got #{mode.inspect}"
@@ -73,6 +104,104 @@ module GraphWeaver
73
104
 
74
105
  @mode = mode
75
106
  end
107
+
108
+ def default_mode=(mode)
109
+ unless mode.nil? || CLIENT_MODES.include?(mode)
110
+ raise ArgumentError,
111
+ "default_mode: must be one of #{CLIENT_MODES.inspect} (or nil to leave " \
112
+ "GraphWeaver.client alone), got #{mode.inspect}"
113
+ end
114
+
115
+ @default_mode = mode
116
+ end
117
+
118
+ # Router arguments — both keys optional, and each answers a different
119
+ # question. `supergraph:` is for one derivation can't find; without it
120
+ # the conventional dump is used, when that dump is itself a supergraph.
121
+ # `subgraphs:` is for what derivation can't settle, or for `"reviews"
122
+ # => :fake`, which fabricates a subgraph this process doesn't serve —
123
+ # the commonest reason to configure a router at all, and no reason to
124
+ # have to restate where the supergraph is.
125
+ def router=(arguments)
126
+ unless arguments.nil? || arguments.is_a?(Hash)
127
+ raise ArgumentError, "router: must be the arguments to build one, e.g. " \
128
+ "{ supergraph: \"supergraph.graphql\" } or { subgraphs: { \"reviews\" => :fake } }, " \
129
+ "got #{arguments.inspect}"
130
+ end
131
+ if arguments&.key?(:context)
132
+ # the rspec hook resets the router's context from config.context
133
+ # every example, so one set here would silently never be read
134
+ raise ArgumentError, "router: context: is set as config.context — the baseline every " \
135
+ ":in_process and :router example starts from"
136
+ end
137
+ unknown = (arguments&.keys || []) - %i[supergraph subgraphs]
138
+ raise ArgumentError, "router: doesn't take #{unknown.join(", ")}" if unknown.any?
139
+
140
+ @router = arguments
141
+ @built_router = nil
142
+ end
143
+
144
+ # Built once: parsing the supergraph is setup, not per-example work.
145
+ # #context is settable, so an example that runs as someone else sets
146
+ # that rather than rebuilding — the rspec hook resets it each time.
147
+ def built_router
148
+ @built_router ||= Router.new(supergraph: supergraph!, subgraphs: @router && @router[:subgraphs])
149
+ end
150
+
151
+ # The composed supergraph :router plans against — named, or the
152
+ # conventional dump when that's what it is. A client can't supply
153
+ # one: its schema is the API schema a router serves, with the
154
+ # @join__* routing table stripped out.
155
+ def supergraph!
156
+ return @router[:supergraph] if @router&.key?(:supergraph)
157
+
158
+ path = GraphWeaver::SchemaLoader.locate_path
159
+ return path if path && supergraph?(path)
160
+
161
+ raise GraphWeaver::Error, ":router needs the composed supergraph SDL — a client's schema " \
162
+ "is the API schema the router serves, with the @join__* routing table stripped out, so " \
163
+ "the supergraph has to be named. #{path ? "#{path} carries no @join__* markers" : "Nothing on disk at #{GraphWeaver.schema_path}"}. " \
164
+ "Set GraphWeaver::Testing.config.router = { supergraph: \"supergraph.graphql\" }."
165
+ end
166
+
167
+ # The live schema class :in_process runs when the example didn't name
168
+ # one — config.schema if that is a class, else whatever the app's own
169
+ # client already runs in-process. Only a live class has resolvers, so
170
+ # there is nothing else to fall back to: a dump is type information.
171
+ def schema_class!
172
+ runnable(schema) || GraphWeaver.live_schema ||
173
+ raise(GraphWeaver::Error, ":in_process runs your resolvers, so it needs the live " \
174
+ "GraphQL::Schema class — and GraphWeaver.client isn't running one in-process to " \
175
+ "borrow. Name it in the example — graphql_in_process(MySchema) — or set " \
176
+ "GraphWeaver::Testing.config.schema = MySchema for the whole suite. A federated app " \
177
+ "names the subgraph it means, per example; graphql: :router runs the graph stitched.")
178
+ end
179
+
180
+ # The schema everything else derives from: the one you set, else the
181
+ # committed dump, else the schema the app's client talks to.
182
+ def reference_schema!
183
+ found = schema || (GraphWeaver.client.schema if GraphWeaver.client.respond_to?(:schema))
184
+ return found if found
185
+
186
+ raise GraphWeaver::Error, "no schema to run against — GraphWeaver.client isn't set, " \
187
+ "there's no schema dump at #{GraphWeaver.schema_path}, and " \
188
+ "GraphWeaver::Testing.config.schema is unset. Set any one of them."
189
+ end
190
+
191
+ private
192
+
193
+ # config.schema doubles as the :in_process class when it is one — but a
194
+ # dump has no resolvers, so it can only ever be type information.
195
+ def runnable(schema)
196
+ schema if schema.is_a?(Class) && schema <= GraphQL::Schema
197
+ end
198
+
199
+ def supergraph?(source)
200
+ GraphWeaver::SchemaLoader.routing_table(source)
201
+ true
202
+ rescue GraphWeaver::Error
203
+ false
204
+ end
76
205
  end
77
206
 
78
207
  class << self
@@ -82,6 +211,17 @@ module GraphWeaver
82
211
 
83
212
  def configure
84
213
  yield config
214
+ # a typo'd override key pins nothing and the test still passes, so
215
+ # catch it here — while the block that set it is still on the stack
216
+ validate_overrides!(config.explicit_schema, config.overrides) if config.explicit_schema
217
+ config
218
+ end
219
+
220
+ # Override keys name schema coordinates: "Type.field", or a bare field
221
+ # name matching that field on any type. Anything else is a typo that
222
+ # would silently fabricate random data instead of pinning a value.
223
+ def validate_overrides!(schema, overrides)
224
+ overrides.each_key { |key| validate_override_key!(schema, key.to_s) }
85
225
  end
86
226
 
87
227
  # back to defaults — between tests, or to undo an experiment
@@ -94,7 +234,54 @@ module GraphWeaver
94
234
  def cassette_path(name)
95
235
  return name if name.include?("/") || name.end_with?(".yml", ".yaml")
96
236
 
97
- File.join(config.cassette_dir, "#{name}.yml")
237
+ File.join(cassette_dir, "#{name}.yml")
238
+ end
239
+
240
+ # The configured directory, against Rails.root when there is one — a rake
241
+ # task runs from wherever it runs from; the cassettes don't move.
242
+ # const_get rather than a bare Rails: sorbet can't resolve a constant the
243
+ # gem doesn't depend on.
244
+ def cassette_dir
245
+ dir = config.cassette_dir
246
+ root = (Object.const_get(:Rails).root if Object.const_defined?(:Rails))
247
+ root ? File.join(root.to_s, dir) : dir
248
+ rescue NoMethodError
249
+ dir # something else named Rails
250
+ end
251
+
252
+ private
253
+
254
+ def validate_override_key!(schema, key)
255
+ type_name, field_name = key.split(".", 2)
256
+ # introspection fields (__typename) are real but absent from #fields
257
+ return if (field_name || type_name).start_with?("__")
258
+
259
+ if field_name.nil?
260
+ known = field_names(schema)
261
+ return if known.include?(type_name)
262
+
263
+ bad_override!(key, "matches no field in this schema", known, type_name)
264
+ end
265
+
266
+ type = schema.get_type(type_name)
267
+ unless type.respond_to?(:fields)
268
+ bad_override!(key, "names no object type in this schema", schema.types.keys, type_name)
269
+ end
270
+ return if type.fields.key?(field_name)
271
+
272
+ bad_override!(key, "is not a field of #{type_name}", type.fields.keys, field_name)
273
+ end
274
+
275
+ def bad_override!(key, problem, dictionary, term)
276
+ suggestion = GraphWeaver.did_you_mean(dictionary, term)
277
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
278
+ raise GraphWeaver::Error, "override key #{key.inspect} #{problem}#{hint}"
279
+ end
280
+
281
+ # Every output field name in the schema — walked only when a bare key
282
+ # asks for it.
283
+ def field_names(schema)
284
+ schema.types.each_value.flat_map { |type| type.respond_to?(:fields) ? type.fields.keys : [] }.uniq
98
285
  end
99
286
  end
100
287
  end
@@ -102,5 +289,8 @@ end
102
289
 
103
290
  require_relative "testing/values"
104
291
  require_relative "testing/fake_client"
292
+ require_relative "testing/fake_subgraph"
105
293
  require_relative "testing/failure"
106
294
  require_relative "testing/cassette"
295
+ require_relative "testing/router"
296
+ require_relative "testing/coverage"
@@ -29,27 +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/
36
+ # timeouts, so they'd be silently dropped — fail loudly instead
37
+ unless headers.empty? && block.nil? && open_timeout.nil? && read_timeout.nil?
38
+ raise ArgumentError,
39
+ "headers:/timeouts/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
40
+ end
41
+
35
42
  url_or_connection
36
43
  else
37
- # Faraday appends the default adapter when the block doesn't set one
38
- ::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
+ )
39
58
  end
40
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})" }
41
65
  end
42
66
 
43
67
  private
44
68
 
45
- sig { override.params(body: String).returns([Integer, T.untyped]) }
69
+ sig { override.params(body: String).returns(T::Array[T.untyped]) }
46
70
  def post(body)
47
71
  response = @connection.post do |request|
48
- 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 }
49
74
  request.body = body
50
75
  end
51
76
 
52
- [response.status, response.body]
77
+ [response.status, response.body, response.headers.to_h.transform_keys(&:downcase)]
53
78
  end
54
79
  end
55
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