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.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -4
- data/README.md +40 -88
- data/docs/alternatives.md +1 -7
- data/docs/cassettes.md +54 -59
- data/docs/editors.md +32 -47
- data/docs/errors.md +261 -369
- data/docs/federation.md +650 -837
- data/docs/generated_modules.md +370 -459
- data/docs/getting_started.md +211 -428
- data/docs/i18n.md +114 -177
- data/docs/logging.md +127 -116
- data/docs/real_world.md +26 -39
- data/docs/scalars.md +277 -310
- data/docs/testing.md +340 -486
- data/docs/transports.md +191 -263
- data/docs/upgrading.md +188 -560
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +12 -3
- data/lib/graph_weaver/client.rb +22 -1
- data/lib/graph_weaver/codegen.rb +5 -1
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +23 -15
- data/lib/graph_weaver/federation.rb +11 -2
- data/lib/graph_weaver/in_process.rb +15 -9
- data/lib/graph_weaver/internal/endpoint.rb +7 -5
- data/lib/graph_weaver/internal/headers.rb +19 -0
- data/lib/graph_weaver/internal.rb +66 -13
- data/lib/graph_weaver/log_subscriber.rb +10 -2
- data/lib/graph_weaver/logging.rb +33 -13
- data/lib/graph_weaver/query_module.rb +8 -0
- data/lib/graph_weaver/retry.rb +12 -8
- data/lib/graph_weaver/schema_loader.rb +52 -14
- data/lib/graph_weaver/testing/cassette.rb +28 -5
- data/lib/graph_weaver/testing/endpoint.rb +14 -13
- data/lib/graph_weaver/testing/fake_client.rb +33 -3
- data/lib/graph_weaver/testing/router.rb +7 -3
- data/lib/graph_weaver/transport/http.rb +2 -2
- data/lib/graph_weaver/transport.rb +47 -23
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +22 -1
- metadata +16 -3
- 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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
-
- `open_timeout:` / `read_timeout:` — seconds, defaulting to 10 and 30 on
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
GraphWeaver.
|
|
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
|
-
##
|
|
43
|
+
## What fills the client slot
|
|
81
44
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
GraphWeaver::
|
|
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
|
-
**
|
|
134
|
-
opens a fresh connection per request
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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,
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
`
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
`
|
|
350
|
-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|