graph_weaver 0.7.0 → 0.7.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +370 -459
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +340 -486
  16. data/docs/transports.md +191 -263
  17. data/docs/upgrading.md +188 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +22 -1
  33. data/lib/graph_weaver/codegen.rb +5 -1
  34. data/lib/graph_weaver/context_seam.rb +54 -0
  35. data/lib/graph_weaver/errors.rb +23 -15
  36. data/lib/graph_weaver/federation.rb +11 -2
  37. data/lib/graph_weaver/in_process.rb +15 -9
  38. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  39. data/lib/graph_weaver/internal/headers.rb +19 -0
  40. data/lib/graph_weaver/internal.rb +66 -13
  41. data/lib/graph_weaver/log_subscriber.rb +10 -2
  42. data/lib/graph_weaver/logging.rb +33 -13
  43. data/lib/graph_weaver/query_module.rb +8 -0
  44. data/lib/graph_weaver/retry.rb +12 -8
  45. data/lib/graph_weaver/schema_loader.rb +52 -14
  46. data/lib/graph_weaver/testing/cassette.rb +28 -5
  47. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  48. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  49. data/lib/graph_weaver/testing/router.rb +7 -3
  50. data/lib/graph_weaver/transport/http.rb +2 -2
  51. data/lib/graph_weaver/transport.rb +47 -23
  52. data/lib/graph_weaver/version.rb +1 -1
  53. data/lib/graph_weaver.rb +22 -1
  54. metadata +16 -3
  55. data/CHANGELOG.md +0 -3801
data/docs/transports.md CHANGED
@@ -5,82 +5,71 @@ bundled HTTP transports, and how a generated module decides which one to use.
5
5
  Read it when the default one-liner isn't enough — custom headers, mTLS, Faraday
6
6
  middleware, retries, or connection pooling under load.
7
7
 
8
- A *client* is anything with `execute(query, variables:, operation_name:)` whose result
9
- `to_h`s into `{"data" => ..., "errors" => ...}` — from a full
10
- `GraphWeaver::Client` down to a schema class
11
- ([in-process execution](getting_started.md#your-apps-own-schema-in-process) —
12
- typed access to your own app's API, no socket), a [FakeClient](testing.md), or
13
- anything you write. Every slot that takes a client accepts any of them.
14
-
15
- **Anything holding a schema parses against it.** `client.parse(query)`, and
16
- the same on `InProcess`, `FakeClient` and `Testing::Router` — a typed module
17
- bound to that schema, running on that object, without naming either. It sits
18
- on top of the contract rather than in it: `Retry` wraps a client and holds no
19
- schema, so it has no `parse`, and a bare schema class fills the client slot
20
- without one. `load_queries!` is the same rule over a directory.
21
-
22
- A *transport* is the network end of that contract — GraphQL-over-HTTP. The bundled
23
- two — `Transport::HTTP` (net/http, zero dependencies, loaded by default)
24
- and `Transport::Faraday` (opt-in: naming it loads faraday, so an app that
25
- doesn't needs no faraday) — subclass `GraphWeaver::Transport`,
26
- which owns the shared flow: encode the request, reclassify network
27
- failures as `TransportError`, raise `ServerError` on non-2xx, parse the
28
- body. A subclass only implements `post(body) => [status, body]` — that's
29
- the whole recipe for bringing your own HTTP client. Return the response
30
- headers as a third element, downcased, and `ServerError#headers` carries
31
- them; two elements is still a complete answer.
32
-
33
- ## One-shot setup: a client
34
-
35
- Most apps need one line:
8
+ ## A client in one line
9
+
10
+ Most apps need this:
36
11
 
37
12
  ```ruby
38
13
  github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
39
14
  ```
40
15
 
41
- `GraphWeaver.new` builds a [`Client`](real_world.md): a transport with
42
- auth applied (exposed as `client.transport`), the schema introspected
43
- lazily, and `parse`/`run` bound to both. A `Client` answers the client
44
- contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
45
- `subgraphs:`, a cassette recorder.
16
+ `GraphWeaver.new` builds a [`Client`](real_world.md): a transport with auth
17
+ applied (exposed as `client.transport`), the schema introspected lazily, and
18
+ `parse`/`run` bound to both.
46
19
 
47
20
  - `auth:` — a token, or something answering `#call` that returns one per
48
21
  request; "Bearer" is assumed unless it carries its own scheme
49
22
  (`"Basic dXNlcjpwYXNz..."`)
50
23
  - `transport:` — `:http` (the default) or `:faraday`
51
24
  - `headers:` — anything else (API keys, custom headers)
52
- - `retries:` — off by default; every other [`Retry`](#retries) option sits
53
- beside it (`backoff:`, `retry_codes:`, ...)
54
- - `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
55
- either transport
25
+ - `retries:` — off by default; every other [`Retry`](#retries) option sits beside
26
+ it (`backoff:`, `retry_codes:`, ...)
27
+ - `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on either
28
+ transport
56
29
  - `pool_size:` — how many sockets the bundled HTTP transport keeps open,
57
- defaulting to `RAILS_MAX_THREADS` (else 5). Refused with
58
- `transport: :faraday`, whose adapter owns its own connections — a ceiling
59
- here would be a number nothing reads
30
+ defaulting to `RAILS_MAX_THREADS` (else 5). Refused with `transport: :faraday`,
31
+ whose adapter owns its own connections — a ceiling here would be a number
32
+ nothing reads
60
33
  - `cache:` / `ttl:` — schema introspection caching (see
61
34
  [real world](real_world.md)); url clients only — a schema source never
62
35
  introspects, so passing them raises
63
36
  - a block customizes the Faraday connection (Faraday only — raises without it)
64
37
 
65
- They combine, so the whole thing is still one call:
66
-
67
- ```ruby
68
- GraphWeaver.new(url, transport: :faraday, retries: 2) do |conn|
69
- conn.response :logger
70
- end
71
- ```
72
-
73
- What you pass is what you get; the client logs which transport it built at
74
- `info`.
75
-
76
- To wire generated modules that don't bake a client, make it the app's
77
- default: `GraphWeaver.client = github`. Anything satisfying the execute
78
- contract works there — testing's `graphql:` tag swaps in a client per example.
38
+ They combine, so the whole thing is still one call
39
+ `GraphWeaver.new(url, transport: :faraday, retries: 2) { |conn| conn.response :logger }`.
40
+ To wire generated modules that don't bake a client, make it the app's default:
41
+ `GraphWeaver.client = github`.
79
42
 
80
- ## Building blocks
43
+ ## What fills the client slot
81
44
 
82
- The client is convenience, not the only door — construct and assign
83
- yourself for full control:
45
+ A *client* is anything with `execute(query, variables:, operation_name:)` whose
46
+ result `to_h`s into `{"data" => ..., "errors" => ...}` — from a full
47
+ `GraphWeaver::Client` down to a schema class
48
+ ([in-process execution](getting_started.md#your-apps-own-schema-in-process) —
49
+ typed access to your own app's API, no socket), a [FakeClient](testing.md), or
50
+ anything you write. Every slot that takes a client accepts any of them, `Retry`
51
+ and a cassette recorder included.
52
+
53
+ **Anything holding a schema parses against it** — `client.parse(query)`, and the
54
+ same on `InProcess`, `FakeClient` and `Testing::Router`, giving a typed module
55
+ bound to that schema and running on that object. It sits on top of the contract
56
+ rather than in it, so `Retry`, which holds no schema, has no `parse`.
57
+ `load_queries!` is the same rule over a directory.
58
+
59
+ A *transport* is the network end of that contract — GraphQL-over-HTTP. Both
60
+ bundled ones subclass `GraphWeaver::Transport`, which owns the shared flow: encode
61
+ the request, reclassify network failures as `TransportError`, raise `ServerError`
62
+ on non-2xx, parse the body. A subclass only implements
63
+ `post(body) => [status, body]` — the whole recipe for bringing your own HTTP
64
+ client. Return the response headers as a downcased third element and
65
+ `ServerError#headers` carries them; two elements is still a complete answer.
66
+
67
+ ## The two transports
68
+
69
+ `Transport::HTTP` (net/http, zero dependencies, loaded by default) is the
70
+ default. `Transport::Faraday` is opt-in — naming it loads faraday, so an app that
71
+ doesn't need it needs no faraday — and is the one to reach for when you already
72
+ have a `Faraday::Connection`, or want its middleware.
84
73
 
85
74
  ```ruby
86
75
  # zero-dependency Net::HTTP — a pool of persistent (keep-alive)
@@ -103,8 +92,7 @@ GraphWeaver::Transport::HTTP.new(
103
92
  )
104
93
 
105
94
  # Faraday: a url (+ optional middleware block), or a ready connection.
106
- # Timeouts default to the same 10/30 as Transport::HTTP without them
107
- # Faraday inherits net/http's 60s/60s.
95
+ # Timeouts default to the same 10/30 (Faraday's own would be 60/60).
108
96
  GraphWeaver::Transport::Faraday.new(url, open_timeout: 10, read_timeout: 30)
109
97
  GraphWeaver::Transport::Faraday.new(url) do |conn|
110
98
  conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
@@ -112,72 +100,58 @@ GraphWeaver::Transport::Faraday.new(url) do |conn|
112
100
  end
113
101
  GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
114
102
 
115
- # One Faraday::Connection is reused for the transport's lifetime, but
116
- # socket keep-alive depends on the ADAPTER (see below) the transport
117
- # logs the one it ended up with at :info:
118
- GraphWeaver::Transport::Faraday.new(url) do |conn|
119
- conn.adapter :net_http_persistent
120
- end
121
-
122
- # In-process: a live graphql-ruby schema class, no socket — typed access
123
- # to your own app's API. The class alone works in any client slot; the
124
- # wrapper adds a request context, the same debug logging the network
125
- # transports emit, and errors branded under GraphWeaver::Error (a resolver
126
- # raise becomes a ServerError, status 500, with the original as #cause).
103
+ # In-process: a live graphql-ruby schema class, no socket. The class alone
104
+ # works in any client slot; the wrapper adds a request context, the debug
105
+ # logging the network transports emit, and errors branded under
106
+ # GraphWeaver::Error (a resolver raise becomes a ServerError, status 500).
127
107
  GraphWeaver::InProcess.new(MySchema, context: { current_user: user })
128
108
  GraphWeaver.new(MySchema, context: { current_user: user }) # same, via a client
129
109
 
130
110
  GraphWeaver.client = ... # the app default (a Client or any of the above)
131
111
  ```
132
112
 
133
- **Keeping Faraday's sockets alive.** Faraday's default `net_http` adapter
134
- opens a fresh connection per request 10 TCP connections for 10 requests,
135
- and over HTTPS a TLS handshake each time. `:net_http_persistent` is the
136
- adapter that gets Faraday the connection reuse and thread-safe pooling
137
- `Transport::HTTP` has by default. It needs two gems, and the version
138
- pairing matters — **Faraday 2.x requires `faraday-net_http_persistent`
139
- 2.x**; the Faraday-1.x-era 1.2.0 raises `NoMethodError: undefined method
140
- 'dependency' for class Faraday::Adapter::NetHttpPersistent` at load:
113
+ **Faraday's sockets need an adapter to stay alive.** Its default `net_http`
114
+ adapter opens a fresh connection per request, TLS handshake and all;
115
+ `:net_http_persistent` gets it the reuse and thread-safe pooling
116
+ `Transport::HTTP` has by default. graph_weaver depends on neither and never
117
+ selects an adapter for you, and the version pairing matters — **Faraday 2.x
118
+ requires `faraday-net_http_persistent` 2.x**:
141
119
 
142
120
  ```ruby
143
121
  gem "net-http-persistent" # the HTTP client
144
122
  gem "faraday-net_http_persistent", "~> 2.0" # the Faraday adapter for it
145
- ```
146
-
147
- graph_weaver depends on neither and never selects an adapter for you.
148
-
149
- **Headers.** Both transports send `Content-Type: application/json`,
150
- `Accept: application/graphql-response+json, application/json;q=0.9` (the
151
- media type [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/draft/)
152
- requires a conforming client to accept, with the legacy type as
153
- fallback), and `User-Agent: graph_weaver/<version>` so a server operator
154
- can attribute the traffic. Anything you pass in `headers:` wins over
155
- these. A prebuilt `Faraday::Connection` owns its own headers; only the
156
- ones it leaves unset are filled in — and Faraday's stock
157
- `User-Agent: Faraday v…`, which it fills in for every connection whether
158
- you asked or not, counts as unset.
159
-
160
- **Who the graph thinks is calling.** Both transports also send
161
- `apollographql-client-name` and `apollographql-client-version`, which is
162
- what an Apollo Router or GraphOS keys client attribution on — per-client
163
- SLOs and rate limits, and "who still asks for this deprecated field". The
164
- name is your Rails application's (`Storefront`), or `graph_weaver` outside
165
- Rails, since Apollo means the consuming *application*; the version is the
166
- gem's, because graph_weaver can't know what your app calls its releases.
167
- Both are plain headers, so `headers:` overrides them — which is how one app
168
- names its several clients apart:
169
123
 
170
- ```ruby
171
- GraphWeaver::Transport::HTTP.new(url, headers: {
172
- "apollographql-client-name" => "storefront-checkout",
173
- "apollographql-client-version" => ENV.fetch("GIT_SHA"),
174
- })
124
+ GraphWeaver::Transport::Faraday.new(url) { |conn| conn.adapter :net_http_persistent }
175
125
  ```
176
126
 
177
- **A header that expires.** A header *value* may be anything answering `#call`,
178
- on either transport, resolved per request rather than captured when the
179
- transport was built the same way a graph's [`schema`](federation.md) takes a
180
- lambda. A value (or a call) of `nil` sends no such header; anything else is
127
+ **Compression and proxies** need no configuration on either transport. `net/http`
128
+ which both use underneath asks for `gzip`/`deflate` on every request and
129
+ decodes what comes back, and it reads `http_proxy` / `HTTPS_PROXY` and `no_proxy`
130
+ from the environment. A proxy is never used for a loopback address, which is
131
+ Ruby's rule, not ours.
132
+
133
+ ## Headers
134
+
135
+ Both transports send `Content-Type: application/json`, `Accept:
136
+ application/graphql-response+json, application/json;q=0.9` (the media type
137
+ [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/draft/) requires
138
+ a conforming client to accept, with the legacy type as fallback), and
139
+ `User-Agent: graph_weaver/<version>`. Anything you pass in `headers:` wins over
140
+ these. A prebuilt `Faraday::Connection` owns its own headers; only the ones it
141
+ leaves unset are filled in — and Faraday's stock `User-Agent: Faraday v…` counts
142
+ as unset.
143
+
144
+ **Who the graph thinks is calling.** Both also send `apollographql-client-name`
145
+ and `apollographql-client-version`, which is what an Apollo Router or GraphOS keys
146
+ client attribution on — per-client SLOs and rate limits, and "who still asks for
147
+ this deprecated field". The name is your Rails application's (`Storefront`), or
148
+ `graph_weaver` outside Rails, since Apollo means the consuming *application*; the
149
+ version is the gem's. Both are plain headers, so `headers:` overrides them, which
150
+ is how one app names its several clients apart.
151
+
152
+ **A header that expires.** A header *value* may be anything answering `#call`, on
153
+ either transport, resolved per request rather than captured when the transport
154
+ was built. A value (or a call) of `nil` sends no such header; anything else is
181
155
  sent as its `to_s`, so a numeric tenant id needs no ceremony:
182
156
 
183
157
  ```ruby
@@ -192,118 +166,62 @@ GraphWeaver::Transport::HTTP.new(url, headers: {
192
166
  `Faraday::Connection` owns its own headers, so a rotating credential there is
193
167
  Faraday's middleware (`conn.request :authorization, "Bearer", -> { ... }`).
194
168
 
195
- **Compression and proxies** need no configuration on either transport.
196
- `net/http` — which both use underneath — asks for `gzip`/`deflate` on every
197
- request and decodes what comes back, and it reads `http_proxy` / `HTTPS_PROXY`
198
- and `no_proxy` from the environment. A proxy is never used for a loopback
199
- address, which is Ruby's rule, not ours.
200
-
201
- **The endpoint an error names** is the url with its userinfo and any secret
202
- query parameter folded to `[FILTERED]` see [errors](errors.md). `#url` on a
203
- transport stays the real endpoint; `#safe_url` is the one that goes in a log
204
- line, an exception or an APM payload.
205
-
206
- **Request body.** `{"query": ..., "variables": ...}`, plus
207
- `"operationName"` when the operation has a name the field Apollo Studio,
208
- Hasura and most APMs key traces, rate limits and slow-query reports on.
209
- Generated modules always send one an anonymous document is named after its
210
- module at generation, so the name is declared in the query too. A raw query
211
- string handed straight to a transport falls back to the name in the document,
212
- and a genuinely anonymous one sends no `operationName` key at all.
213
-
214
- **Variables have to be JSON.** The body is one `application/json` document, so
215
- every variable value, at any depth, must be something JSON carries: a string,
216
- a number, a boolean, null, a list, an object — or a value with an honest
217
- string form, which is how a `Date`, a `Time`, a `BigDecimal` or a `Symbol`
218
- travels. A `File`, an `IO`, a `Pathname` or a plain object is refused before
219
- the body is built, naming the variable: JSON would otherwise render it as its
220
- `#to_s`, so `$file` reaches the server as `"#<File:0x00007f…>"` and is stored
221
- as if it meant something. graph_weaver does not implement the [GraphQL
222
- multipart request
169
+ ## The request body
170
+
171
+ `{"query": ..., "variables": ...}`, plus `"operationName"` when the operation has
172
+ a name the field Apollo Studio, Hasura and most APMs key traces, rate limits
173
+ and slow-query reports on. Generated modules always send one: an anonymous
174
+ document is named after its module at generation, so the name is declared in the
175
+ query too. A raw query string handed straight to a transport falls back to the
176
+ name in the document, and a genuinely anonymous one sends no `operationName` key
177
+ at all.
178
+
179
+ **Variables have to be JSON.** Every variable value, at any depth, must be
180
+ something JSON carries a string, a number, a boolean, null, a list, an object —
181
+ or a value with an honest string form, which is how a `Date`, a `Time`, a
182
+ `BigDecimal` or a `Symbol` travels. A `File`, an `IO`, a `Pathname` or a plain
183
+ object is refused before the body is built, naming the variable: JSON would
184
+ otherwise render it as its `#to_s`, so `$file` reaches the server as
185
+ `"#<File:0x00007f…>"` and is stored as if it meant something. The refusal is the
186
+ *call's*, not the transport's, so `graphql: :in_process` and `graphql: :fake`
187
+ refuse the same value with the same sentence.
188
+
189
+ graph_weaver does not implement the [GraphQL multipart request
223
190
  spec](https://github.com/jaydenseric/graphql-multipart-request-spec), so an
224
- `Upload!` argument needs your own transport or a separate upload endpoint
225
- registering a scalar can't help, because multipart restructures the whole
226
- request rather than one value.
191
+ `Upload!` argument needs your own transport or a separate upload endpoint;
192
+ registering a scalar can't help, since multipart restructures the whole request.
227
193
 
228
194
  **No persisted-query id goes with it**, so a gateway safelist configured with
229
- `require_id` refuses every request this client makes; automatic persisted
230
- queries (APQ) are an optimization, so those just never kick in. Until the gem
231
- sends one, `post` is the seam — it sees the encoded body and can put the hash
232
- beside it:
233
-
234
- ```ruby
235
- class APQ < GraphWeaver::Transport::HTTP
236
- def post(body)
237
- request = JSON.parse(body)
238
- sha = Digest::SHA256.hexdigest(request.fetch("query"))
239
- extensions = { "persistedQuery" => { "version" => 1, "sha256Hash" => sha } }
240
- status, response, headers = super(JSON.generate(request.except("query").merge("extensions" => extensions)))
241
- return [status, response, headers] unless response.to_s.include?("PersistedQueryNotFound")
242
-
243
- super(JSON.generate(request.merge("extensions" => extensions))) # register on miss
244
- end
245
- end
246
- ```
195
+ `require_id` refuses every request this client makes; automatic persisted queries
196
+ (APQ) are an optimization, so those just never kick in. `post` is the seam if you
197
+ need one — it sees the encoded body and can put the hash beside it.
247
198
 
248
- **Concurrency.** One transport is normally the whole app's transport
249
- (`GraphWeaver.client = api`), so it has to serve every thread.
250
- `Transport::HTTP` opens up to `pool_size:` sockets lazily and reuses the
251
- warmest one; requests beyond that queue for a free slot rather than
252
- opening unbounded connections. A socket that errors is closed and its slot
253
- left empty, so the next call reconnects.
254
-
255
- A url client introspects its schema lazily, and the first requests of a cold
256
- process arrive together — so that fetch is done **once**, by whoever asks
257
- first, with the rest waiting on it rather than each making its own round trip
258
- and writing its own copy of the schema cache.
259
-
260
- `pool_size:` defaults to `RAILS_MAX_THREADS` (else 5) — the same variable
261
- Rails sizes its own connection pool from, because it is the same question:
262
- how many requests this process can have in flight at once. Lower it for a
263
- server that counts connections.
264
-
265
- **The pool is fork-safe**, which is what a Puma or Unicorn worker under
266
- `preload_app!` needs. A socket warmed before the fork — an initializer that
267
- introspects the schema is enough — is otherwise inherited by every worker, and
268
- nothing in a round trip says which process opened it, so two workers
269
- interleaving on one fd hand each other's answers back. A child notices the pid
270
- changed and starts over: the inherited sockets are **abandoned rather than
271
- closed** (closing would take down the fd the parent is still using) and
272
- reconnect on first use, and the permits are rebuilt, since any held at fork time
273
- went with the threads that held them. There is no `after_fork` hook to write.
274
-
275
- Under a fiber scheduler (`async`, Falcon) everything here works unchanged —
276
- `SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware,
277
- so requests multiplex on one thread at thread-equivalent throughput. But
278
- `pool_size:` is the same hard ceiling there, and nothing sets
279
- `RAILS_MAX_THREADS` for you, so set it to the concurrency you expect.
280
- Saturation is not silent: the first request that has to queue logs a warning
281
- naming how long it waited and what to raise.
199
+ `#url` on a transport is the real endpoint; `#safe_url` — userinfo and secret
200
+ query parameters folded to `[FILTERED]` is what a log line, an exception or an
201
+ APM payload gets ([errors](errors.md)).
282
202
 
283
203
  ## Client resolution
284
204
 
285
- The canonical order — how a generated module finds its client (each slot
286
- takes a `Client` or any bare transport/fake):
205
+ The canonical order — how a generated module finds its client (each slot takes a
206
+ `Client` or any bare transport/fake):
287
207
 
288
- 1. per call: `execute(client: some_client, ...)` — a kwarg like the
289
- variables, and a name no GraphQL variable is allowed to take
208
+ 1. per call: `execute(client: some_client, ...)` — a kwarg like the variables, and
209
+ a name no GraphQL variable is allowed to take
290
210
  2. per module: `MyQuery.client = something`
291
- 3. a test mode's stand-in: under `graphql: :fake` / `:in_process` /
292
- `:router`, built from the graph this module was generated from
211
+ 3. a test mode's stand-in: under `graphql: :fake` / `:in_process` / `:router`,
212
+ built from the graph this module was generated from
293
213
  4. baked constant: `Codegen.generate(..., client: "MyApi::CLIENT")` — the
294
214
  constant's *name*, not the object, because generated source spells it
295
215
  5. the app default: `GraphWeaver.client=`
296
216
 
297
217
  The mode replaces what codegen baked in, not what your example said — 1 and 2
298
- still win.
299
-
300
- Nothing set anywhere raises, naming the two you'd usually reach for:
218
+ still win. Nothing set anywhere raises, naming the two you'd usually reach for:
301
219
  `no client configured — set GraphWeaver.client= or pass a client`.
302
220
 
303
221
  ## Retries
304
222
 
305
- A url client retries when you give it a count; the rest of the options
306
- sit beside it:
223
+ A url client retries when you give it a count; the rest of the options sit beside
224
+ it:
307
225
 
308
226
  ```ruby
309
227
  GraphWeaver.new(
@@ -319,69 +237,52 @@ GraphWeaver.new(
319
237
  )
320
238
  ```
321
239
 
322
- `GraphWeaver::Retry.new(inner_transport, ...)` takes the same options and
323
- wraps any client/transport directly — the client just passes them along.
324
-
325
- Defaults: transport failures always retry; a response retries when its
326
- status is 5xx or **408 or 429** — the rest of 4xx is a bug in the request,
327
- retrying won't fix it. That's one rule for both shapes a failure arrives
328
- in: raised as a `ServerError`, or returned in the envelope because the
329
- server sent GraphQL errors alongside the status. Apollo Router does the
330
- latter for everything it decides itself rate limiting is `503` with a
331
- `REQUEST_RATE_LIMITED` body so a policy that read only the raised half
332
- made exactly one attempt behind a router. `retry_codes:` adds the other
333
- signal: error codes, at any status (off by default — pass the codes your
334
- API uses, or `GraphWeaver::GraphQLError::THROTTLE_CODES`). Exhausting the
335
- retries re-raises the last error (or returns the last response).
336
-
337
- A `200` is never retried on its status, whatever it carries. A router that
338
- gives up on a slow subgraph answers `200` with partial data and a
339
- `GATEWAY_TIMEOUT` error: the caller already has an answer, and whether a
340
- partial one is worth repeating is a judgment only the caller can make —
341
- `retry_codes: ["GATEWAY_TIMEOUT"]` is how they say yes.
342
-
343
- **Nothing else retries**, which is the half a script author needs: a `200`
344
- the server stands behind, and an `InputError` (the variables never left the
345
- process), are permanent by constructionthe identical request gets the
346
- identical answer. Only a failure the server itself marked transient, by
347
- status or by code, is worth repeating.
348
-
349
- `retries:` counts the attempts *after* the first, so
350
- `GraphWeaver.new(url, retries: 3)` makes up to four and `retries: 0` never
351
- retries; `retries: true` takes `Retry`'s own default of 2. Every misspelling
352
- raises rather than quietly doing nothing — a retry option passed without a
353
- count says so, and the old Hash form (`retries: { retries: 5 }`) names its
354
- flat replacement.
355
-
356
- **A mutation gets one attempt.** A failure with no answer — a read
357
- timeout, a 502, a reset socket — does not say whether the server applied
358
- it, and a second `charge` is worse than a failed one.
359
- `retry_mutations: true` opts an idempotent API back in; the skipped
360
- retry says so on the logger.
361
-
362
- The cap is on **attempts**, not on a kind of failure: a mutation gets its one
363
- attempt whatever `retry_on:` says, so a `ServerError` is not retried either —
364
- not a 500, not a 429 that named a `Retry-After`. `retry_mutations: true` puts
365
- the mutation back on the same budget as a query, for every one of them.
240
+ `GraphWeaver::Retry.new(inner_transport, ...)` takes the same options and wraps
241
+ any client/transport directly — the client just passes them along. `retries:`
242
+ counts the attempts *after* the first, so `retries: 3` makes up to four and
243
+ `retries: 0` never retries; `retries: true` takes `Retry`'s own default of 2.
244
+ Every misspelling raises rather than quietly doing nothing.
245
+
246
+ **What retries.** Transport failures always do; a response does when its status is
247
+ 5xx or **408 or 429** the rest of 4xx is a bug in the request, and retrying
248
+ won't fix it. That is one rule for both shapes a failure arrives in: raised as a
249
+ `ServerError`, or returned in the envelope because the server sent GraphQL errors
250
+ alongside the status, which is what Apollo Router does for everything it decides
251
+ itself (rate limiting is `503` plus a `REQUEST_RATE_LIMITED` body). `retry_codes:`
252
+ adds the other signal — error codes, at any status, off by default; pass the codes
253
+ your API uses, or `GraphWeaver::GraphQLError::THROTTLE_CODES`. Exhausting the
254
+ retries re-raises the last error, or returns the last response.
255
+
256
+ **Nothing else does.** A `200` is never retried on its status, whatever it
257
+ carries: the caller already has an answer, and whether a partial one is worth
258
+ repeating is a judgment only the caller can make — a router that gives up on a
259
+ slow subgraph answers `200` with partial data and a `GATEWAY_TIMEOUT` error, and
260
+ `retry_codes: ["GATEWAY_TIMEOUT"]` is how you say yes to that one. An `InputError`
261
+ never retries either: the variables never left the process.
262
+
263
+ **A mutation gets one attempt.** A failure with no answer a read timeout, a 502,
264
+ a reset socket does not say whether the server applied it, and a second `charge`
265
+ is worse than a failed one. The cap is on **attempts**, not on a kind of failure,
266
+ so a `ServerError` isn't retried either, not a 500 and not a 429 that named a
267
+ `Retry-After`. `retry_mutations: true` puts mutations back on a query's budget,
268
+ for every one of them; the skipped retry says so on the logger.
366
269
 
367
270
  **Idempotency is the server's.** GraphWeaver never reads an `idempotencyKey`
368
- input: it is an argument like any other, and nothing in the client
369
- deduplicates on it. So before turning `retry_mutations: true` on for a
370
- checkout, the *server* has to dedupe on that key. And either way a failed
371
- response does not mean nothing happened the request that timed out was
372
- still delivered, so the order may exist behind the error your controller
271
+ input, and nothing in the client deduplicates on one, so before turning
272
+ `retry_mutations: true` on for a checkout the *server* has to dedupe. And either
273
+ way a failed response does not mean nothing happened the request that timed out
274
+ was still delivered, so the order may exist behind the error your controller
373
275
  rendered. Reconcile; don't assume.
374
276
 
375
- **`Retry-After` wins over the backoff.** When the server names a delay
376
- (seconds or an HTTP-date), that's the wait the server is the only
377
- party that knows when its window reopens. It's clamped to `max_delay:` so a
378
- "come back in an hour" can't park a thread for an hour, and not
379
- jittered, since it's an instruction rather than a guess.
277
+ **`Retry-After` wins over the backoff**, however the rate limit arrived, raised or
278
+ returned: the server is the only party that knows when its window reopens. It's
279
+ clamped to `max_delay:` so a "come back in an hour" can't park a thread for an
280
+ hour, and not jittered, since it's an instruction rather than a guess.
380
281
 
381
282
  `ServerError` carries the response `#headers`, so the rate-limit budget and
382
283
  request id are in hand without monkey-patching a transport. Look one up in
383
- whatever casing the server used — field names are case-insensitive; iterating
384
- them yields the downcased spelling:
284
+ whatever casing the server used — field names are case-insensitive; iterating them
285
+ yields the downcased spelling:
385
286
 
386
287
  ```ruby
387
288
  rescue GraphWeaver::ServerError => e
@@ -393,4 +294,31 @@ end
393
294
  ```
394
295
 
395
296
  What classifies as a transport failure is an extensible set — see
396
- [errors](errors.md#extending-transporterror) (`GraphWeaver.register_transport_error`).
297
+ [errors](errors.md#extending-transporterror)
298
+ (`GraphWeaver.register_transport_error`).
299
+
300
+ ## Concurrency
301
+
302
+ One transport is normally the whole app's (`GraphWeaver.client = api`), so it has
303
+ to serve every thread. `Transport::HTTP` opens up to `pool_size:` sockets lazily
304
+ and reuses the warmest one; requests beyond that queue for a free slot rather than
305
+ opening unbounded connections. A socket that errors is closed and its slot left
306
+ empty, so the next call reconnects. Saturation is not silent: the first request
307
+ that has to queue logs a warning naming how long it waited and what to raise.
308
+
309
+ A url client introspects its schema lazily, and the first requests of a cold
310
+ process arrive together — so that fetch is done **once**, by whoever asks first,
311
+ with the rest waiting on it rather than each making its own round trip and writing
312
+ its own copy of the schema cache.
313
+
314
+ **The pool is fork-safe**, which is what a Puma or Unicorn worker under
315
+ `preload_app!` needs, and there is no `after_fork` hook to write. A socket warmed
316
+ before the fork would otherwise be inherited by every worker, and two of them
317
+ interleaving on one fd hand each other's answers back; a child notices the pid
318
+ changed and starts over, **abandoning rather than closing** the inherited sockets
319
+ (closing would take down the fd the parent is still using).
320
+
321
+ Under a fiber scheduler (`async`, Falcon) everything here works unchanged —
322
+ `SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware, so
323
+ requests multiplex on one thread at thread-equivalent throughput. But `pool_size:`
324
+ is the same hard ceiling there, and nothing sets `RAILS_MAX_THREADS` for you.