graph_weaver 0.6.1 → 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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
data/docs/transports.md CHANGED
@@ -5,67 +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.
8
+ ## A client in one line
21
9
 
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) — subclass `GraphWeaver::Transport`,
25
- which owns the shared flow: encode the request, reclassify network
26
- failures as `TransportError`, raise `ServerError` on non-2xx, parse the
27
- body. A subclass only implements `post(body) => [status, body]` — that's
28
- the whole recipe for bringing your own HTTP client.
29
-
30
- ## One-shot setup: a client
31
-
32
- Most apps need one line:
10
+ Most apps need this:
33
11
 
34
12
  ```ruby
35
13
  github = GraphWeaver.new("https://api.example.com/graphql", auth: ENV["API_TOKEN"])
36
14
  ```
37
15
 
38
- `GraphWeaver.new` builds a [`Client`](real_world.md): a transport with
39
- auth applied (exposed as `client.transport`), the schema introspected
40
- lazily, and `parse`/`run` bound to both. A `Client` answers the client
41
- contract itself, so it goes anywhere a transport does — `Retry.new(client)`,
42
- `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.
43
19
 
44
- - `auth:` — a token; "Bearer" is assumed unless the string carries its own
45
- scheme (`"Basic dXNlcjpwYXNz..."`)
20
+ - `auth:` — a token, or something answering `#call` that returns one per
21
+ request; "Bearer" is assumed unless it carries its own scheme
22
+ (`"Basic dXNlcjpwYXNz..."`)
46
23
  - `transport:` — `:http` (the default) or `:faraday`
47
24
  - `headers:` — anything else (API keys, custom headers)
48
- - `retries:` — off by default; a count (`retries: 3`), or `true` for the
49
- default count. Every other [`Retry`](#retries) option sits beside it
50
- (`backoff:`, `retry_codes:`, ...)
51
- - `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
52
- 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
29
+ - `pool_size:` — how many sockets the bundled HTTP transport keeps open,
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
53
33
  - `cache:` / `ttl:` — schema introspection caching (see
54
34
  [real world](real_world.md)); url clients only — a schema source never
55
35
  introspects, so passing them raises
56
36
  - a block customizes the Faraday connection (Faraday only — raises without it)
57
37
 
58
- What you pass is what you get; the client logs which transport it built at
59
- `info`.
60
-
61
- To wire generated modules that don't bake a client, make it the app's
62
- default: `GraphWeaver.client = github`. Anything satisfying the execute
63
- 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`.
64
42
 
65
- ## Building blocks
43
+ ## What fills the client slot
66
44
 
67
- The client is convenience, not the only door — construct and assign
68
- 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.
69
73
 
70
74
  ```ruby
71
75
  # zero-dependency Net::HTTP — a pool of persistent (keep-alive)
@@ -88,8 +92,7 @@ GraphWeaver::Transport::HTTP.new(
88
92
  )
89
93
 
90
94
  # Faraday: a url (+ optional middleware block), or a ready connection.
91
- # Timeouts default to the same 10/30 as Transport::HTTP without them
92
- # Faraday inherits net/http's 60s/60s.
95
+ # Timeouts default to the same 10/30 (Faraday's own would be 60/60).
93
96
  GraphWeaver::Transport::Faraday.new(url, open_timeout: 10, read_timeout: 30)
94
97
  GraphWeaver::Transport::Faraday.new(url) do |conn|
95
98
  conn.request :authorization, "Bearer", -> { Tokens.fetch } # dynamic tokens
@@ -97,100 +100,128 @@ GraphWeaver::Transport::Faraday.new(url) do |conn|
97
100
  end
98
101
  GraphWeaver::Transport::Faraday.new(MyApp.faraday_connection)
99
102
 
100
- # One Faraday::Connection is reused for the transport's lifetime, but
101
- # socket keep-alive depends on the ADAPTER (see below) the transport
102
- # logs the one it ended up with at :info:
103
- GraphWeaver::Transport::Faraday.new(url) do |conn|
104
- conn.adapter :net_http_persistent
105
- end
106
-
107
- # In-process: a live graphql-ruby schema class, no socket — typed access
108
- # to your own app's API. The class alone works in any client slot; the
109
- # wrapper adds a request context, the same debug logging the network
110
- # transports emit, and errors branded under GraphWeaver::Error (a resolver
111
- # 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).
112
107
  GraphWeaver::InProcess.new(MySchema, context: { current_user: user })
113
108
  GraphWeaver.new(MySchema, context: { current_user: user }) # same, via a client
114
109
 
115
110
  GraphWeaver.client = ... # the app default (a Client or any of the above)
116
111
  ```
117
112
 
118
- **Keeping Faraday's sockets alive.** Faraday's default `net_http` adapter
119
- opens a fresh connection per request 10 TCP connections for 10 requests,
120
- and over HTTPS a TLS handshake each time. `:net_http_persistent` is the
121
- adapter that gets Faraday the connection reuse and thread-safe pooling
122
- `Transport::HTTP` has by default. It needs two gems, and the version
123
- pairing matters — **Faraday 2.x requires `faraday-net_http_persistent`
124
- 2.x**; the Faraday-1.x-era 1.2.0 raises `NoMethodError: undefined method
125
- '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**:
126
119
 
127
120
  ```ruby
128
121
  gem "net-http-persistent" # the HTTP client
129
122
  gem "faraday-net_http_persistent", "~> 2.0" # the Faraday adapter for it
130
- ```
131
123
 
132
- graph_weaver depends on neither and never selects an adapter for you.
133
-
134
- **Headers.** Both transports send `Content-Type: application/json`,
135
- `Accept: application/graphql-response+json, application/json;q=0.9` (the
136
- media type [GraphQL-over-HTTP](https://graphql.github.io/graphql-over-http/draft/)
137
- requires a conforming client to accept, with the legacy type as
138
- fallback), and `User-Agent: graph_weaver/<version>` so a server operator
139
- can attribute the traffic. Anything you pass in `headers:` wins over
140
- these. A prebuilt `Faraday::Connection` owns its own headers; only the
141
- ones it leaves unset are filled in.
142
-
143
- **Request body.** `{"query": ..., "variables": ...}`, plus
144
- `"operationName"` when the operation has a name — the field Apollo Studio,
145
- Hasura and most APMs key traces, rate limits and slow-query reports on.
146
- Generated modules always send one — an anonymous document is named after its
147
- module at generation, so the name is declared in the query too. A raw query
148
- string handed straight to a transport falls back to the name in the document,
149
- and a genuinely anonymous one sends no `operationName` key at all.
150
-
151
- **Concurrency.** One transport is normally the whole app's transport
152
- (`GraphWeaver.client = api`), so it has to serve every thread.
153
- `Transport::HTTP` opens up to `pool_size:` sockets lazily and reuses the
154
- warmest one; requests beyond that queue for a free slot rather than
155
- opening unbounded connections. A socket that errors is closed and its slot
156
- left empty, so the next call reconnects.
124
+ GraphWeaver::Transport::Faraday.new(url) { |conn| conn.adapter :net_http_persistent }
125
+ ```
157
126
 
158
- A url client introspects its schema lazily, and the first requests of a cold
159
- process arrive together so that fetch is done **once**, by whoever asks
160
- first, with the rest waiting on it rather than each making its own round trip
161
- and writing its own copy of the schema cache.
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
155
+ sent as its `to_s`, so a numeric tenant id needs no ceremony:
162
156
 
163
- `pool_size:` defaults to `RAILS_MAX_THREADS` (else 5) — the same variable
164
- Rails sizes its own connection pool from, because it is the same question:
165
- how many requests this process can have in flight at once. Lower it for a
166
- server that counts connections.
157
+ ```ruby
158
+ GraphWeaver::Transport::HTTP.new(url, headers: {
159
+ "Authorization" => -> { "Bearer #{Tokens.fetch}" }, # rotating token
160
+ "X-Tenant" => -> { Current.tenant&.id }, # nil ⇒ header omitted
161
+ })
162
+ ```
167
163
 
168
- Under a fiber scheduler (`async`, Falcon) everything here works unchanged
169
- `SizedQueue`, `Mutex`, `net/http` and `Kernel#sleep` are all scheduler-aware,
170
- so requests multiplex on one thread at thread-equivalent throughput. But
171
- `pool_size:` is the same hard ceiling there, and nothing sets
172
- `RAILS_MAX_THREADS` for you, so set it to the concurrency you expect.
173
- Saturation is not silent: the first request that has to queue logs a warning
174
- naming how long it waited and what to raise.
164
+ `auth:` is that header under a shorter name, so a rotating credential is
165
+ `GraphWeaver.new(url, auth: -> { Tokens.fetch })`. A prebuilt
166
+ `Faraday::Connection` owns its own headers, so a rotating credential there is
167
+ Faraday's middleware (`conn.request :authorization, "Bearer", -> { ... }`).
168
+
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
190
+ spec](https://github.com/jaydenseric/graphql-multipart-request-spec), so an
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.
193
+
194
+ **No persisted-query id goes with it**, so a gateway safelist configured with
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.
198
+
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)).
175
202
 
176
203
  ## Client resolution
177
204
 
178
- The canonical order — how a generated module finds its client (each slot
179
- 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):
180
207
 
181
- 1. per call: `execute(client: some_client, ...)` — a kwarg like the
182
- 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
183
210
  2. per module: `MyQuery.client = something`
184
- 3. baked constant: `Codegen.generate(..., client: MyApi::CLIENT)`
185
- 4. the app default: `GraphWeaver.client=`
186
-
187
- Nothing set anywhere raises, naming the two you'd usually reach for:
211
+ 3. a test mode's stand-in: under `graphql: :fake` / `:in_process` / `:router`,
212
+ built from the graph this module was generated from
213
+ 4. baked constant: `Codegen.generate(..., client: "MyApi::CLIENT")` — the
214
+ constant's *name*, not the object, because generated source spells it
215
+ 5. the app default: `GraphWeaver.client=`
216
+
217
+ The mode replaces what codegen baked in, not what your example said — 1 and 2
218
+ still win. Nothing set anywhere raises, naming the two you'd usually reach for:
188
219
  `no client configured — set GraphWeaver.client= or pass a client`.
189
220
 
190
221
  ## Retries
191
222
 
192
- A url client retries when you give it a count; the rest of the options
193
- sit beside it:
223
+ A url client retries when you give it a count; the rest of the options sit beside
224
+ it:
194
225
 
195
226
  ```ruby
196
227
  GraphWeaver.new(
@@ -206,46 +237,88 @@ GraphWeaver.new(
206
237
  )
207
238
  ```
208
239
 
209
- `GraphWeaver::Retry.new(inner_transport, ...)` takes the same options and
210
- wraps any client/transport directly — the client just passes them along.
211
-
212
- Defaults: transport failures always retry; `ServerError` on 5xx plus
213
- **408 and 429** the rest of 4xx is a bug in the request, retrying
214
- won't fix it. `retry_codes:` re-inspects response envelopes so
215
- GraphQL-level throttling can retry too (off by default pass the codes
216
- your API uses). Exhausting the retries re-raises the last error (or
217
- returns the last code-matched response).
218
-
219
- `retries:` counts the attempts *after* the first, so
220
- `GraphWeaver.new(url, retries: 3)` makes up to four and `retries: 0` never
221
- retries; `retries: true` takes `Retry`'s own default of 2. Every misspelling
222
- raises rather than quietly doing nothing — a retry option passed without a
223
- count says so, and the old Hash form (`retries: { retries: 5 }`) names its
224
- flat replacement.
225
-
226
- **A mutation gets one attempt.** A failure with no answer a read
227
- timeout, a 502, a reset socketdoes not say whether the server applied
228
- it, and a second `charge` is worse than a failed one.
229
- `retry_mutations: true` opts an idempotent API back in; the skipped
230
- retry says so on the logger.
231
-
232
- **`Retry-After` wins over the backoff.** When the server names a delay
233
- (seconds or an HTTP-date), that's the wait the server is the only
234
- party that knows when its window reopens. It's clamped to `max_delay:` so a
235
- "come back in an hour" can't park a thread for an hour, and not
236
- jittered, since it's an instruction rather than a guess.
237
-
238
- `ServerError` carries the response `#headers` (names downcased), so the
239
- rate-limit budget and request id are in hand without monkey-patching a
240
- transport:
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.
269
+
270
+ **Idempotency is the server's.** GraphWeaver never reads an `idempotencyKey`
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
275
+ rendered. Reconcile; don't assume.
276
+
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.
281
+
282
+ `ServerError` carries the response `#headers`, so the rate-limit budget and
283
+ request id are in hand without monkey-patching a transport. Look one up in
284
+ whatever casing the server used — field names are case-insensitive; iterating them
285
+ yields the downcased spelling:
241
286
 
242
287
  ```ruby
243
288
  rescue GraphWeaver::ServerError => e
244
289
  e.throttled? # 429, or 503 + Retry-After
245
290
  e.retry_after # seconds, or nil
291
+ e.headers["Retry-After"] # == e.headers["retry-after"]
246
292
  e.headers["x-ratelimit-remaining"]
247
293
  end
248
294
  ```
249
295
 
250
296
  What classifies as a transport failure is an extensible set — see
251
- [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.