graphql-armory 0.1.0.pre4 → 0.1.0.pre5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f463135c3578c6248c02f5610cb6bd1bc3f88fe9c26867e8cbd5164c84be408
4
- data.tar.gz: 0344af8ea82c901fb5ad36cd1d236039f30afe3969142305b3e9b48d0890e5ab
3
+ metadata.gz: 431eab2604e80c473f2037cf636158113056cbd60e9fb408c8f6f91696586b55
4
+ data.tar.gz: aaa7d67cd478e2a0b8faf0b069eeb3e7a8051e2304caa55c8e522ddcc1a121bc
5
5
  SHA512:
6
- metadata.gz: 171b49d43c89c1c306949b7e30c56aa71687ccff9f3028f99e8b5f181f4a80b650d8a58e996090013bfa8e800632848109e557842f8ee78aecffbf0893d8d14a
7
- data.tar.gz: 477b8904c178b41c690f2c308004f0d0a8f441d4df144e4ff2cb4e1a06babe6a33c401728edfcc7ebfd0a14c8ae336c8d7ce12bf4de0944b11e2171cdc02c0c8
6
+ metadata.gz: 308f9883d87cd8f5ec8ebc711477779d95a3a149c3fb4e770f9866fc10f6f476c99a788c8493fa03317f3963f71f0aab8da786904ecb78c3ac269f82345ae565
7
+ data.tar.gz: 31179d1a3c3a4b7d5a71027d2b1ce18f983843b10c87e6418c9763a89a33ec33dd7669fdc1bc3dddc2abe0876f37716495c50b4fb05393c58f8b9a38c79015e3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0.pre5 (2026-10-29)
6
+
7
+ Rate limiting (roadmap module 4). Docs: `docs/rate-limiting.md`. All four modules now follow the Useful Objects doctrine.
8
+
9
+ - Rate limiting, part 1: `use GraphQL::Armory::RateLimits::Points, store:, limit:, window:` charges each query its complexity against a bucket of points per client (`context[:rate_limit_key]`) that refills over the window. Over-limit queries are refused before running, with their cost, what remains and when to retry; soft mode (the default) only records. `RateLimits::MemoryStore` for tests and single processes. Docs: `docs/rate-limiting.md`.
10
+ - Rate limiting, part 2: `use GraphQL::Armory::RateLimits::Concurrency, store:, limit:, stale_after: 60` lets each client run at most `limit` operations at the same time. A query analyzer takes a slot before the operation runs (or refuses it, `RATE_LIMITED`); a trace releases every slot when the multiplex ends, even when it raises. Slots expire after `stale_after` seconds. `MemoryStore` gains `acquire` and `release`.
11
+ - Rate limiting, part 3: clients can see their budget on every response. `Points` takes `extensions: true` to add `rateLimit` (`cost`, `limit`, `remaining`, `resetIn`) to the response extensions, and `Points.headers(result)` answers `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`.
12
+ - Rate limiting, part 4: `RateLimits::RedisStore.new(redis:)` keeps buckets and slots in Redis, shared by every process. Each change is one Lua script and the time comes from Redis, so concurrent requests never race and servers agree on the clock. Keys expire on their own. The app adds the redis gem.
13
+ - Rate limiting follows the Useful Objects doctrine like authorization: `Points.new` and `Concurrency.new` are inert and refuse every query (`Store::Null`), `build` checks the settings and wires the store, `call` is the actuator. `RateLimits::Store::Substitute` (a MemoryStore on a test clock that records calls and fails when told) and `RateLimits::Controls` are there for your specs.
14
+ - Encrypted IDs and persisted queries follow the same doctrine: `IDs::Lookup.new` is inert (`Encoder::Null`) and `build` wires `Encoder.default`; `PersistedQueries::Gate` holds the decision, with an inert `new` (`Store::Null`, strict) and `build`. `Encoder::Substitute`, `PersistedQueries::Store::Substitute` and `Controls` for both are there for your specs. `IDs` now refuses at boot a `find:` that is not callable and an encoder without `encode` and `decode`.
15
+
5
16
  ## 0.1.0.pre4 (2026-10-29)
6
17
 
7
18
  Persisted queries / operation allowlist (roadmap module 3). Docs: `docs/persisted-queries.md`.
data/README.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  Open-source extensions for [graphql-ruby](https://github.com/rmosolgo/graphql-ruby), built on its public extension points: no fork, no monkey patching. One gem, one module at a time; each module has its own page in [`docs/`](docs) with the problem it solves, how to use it, and its limits.
8
8
 
9
- > **Status: early development.** Prereleases on RubyGems (`0.1.0.pre4`). The API may change before 1.0.
9
+ > **Status: early development.** Prereleases on RubyGems (`0.1.0.pre5`). The API may change before 1.0.
10
10
 
11
11
  ## Install
12
12
 
13
13
  ```ruby
14
- gem "graphql-armory", "~> 0.1.0.pre4" # a prerelease: `gem install graphql-armory --pre`
14
+ gem "graphql-armory", "~> 0.1.0.pre5" # a prerelease: `gem install graphql-armory --pre`
15
15
  gem "pundit" # or "cancancan", for the authorization module
16
16
  ```
17
17
 
@@ -26,7 +26,7 @@ Status: ✅ released, 🚧 in progress, ⬜ not started. Update this table in th
26
26
  | 1 | Authorization (Pundit, CanCanCan) | ✅ | [docs/authorization.md](docs/authorization.md) | Released in 0.1.0.pre2 (prerelease). Pundit and CanCanCan (the audit checks less with CanCanCan). Global IDs in `loads:` decoded through `object_from_id`; `loads:` inside input objects scoped with `include Base`. `Execution::Next` needs graphql >= 2.6.2. |
27
27
  | 2 | Encrypted, versioned IDs and cursors | ✅ | [docs/encrypted-ids.md](docs/encrypted-ids.md) | Released in 0.1.0.pre4 (prerelease). `Encoder` (AES-256-GCM), versioned chains and `Legacy`, `Encoder.default` (ENV or Rails), `use GraphQL::Armory::IDs`. Composite primary keys not supported. |
28
28
  | 3 | Persisted queries / operation allowlist | ✅ | [docs/persisted-queries.md](docs/persisted-queries.md) | Released in 0.1.0.pre4 (prerelease). Allowlist from a build manifest (Apollo, Relay), `use GraphQL::Armory::PersistedQueries` with strict mode. A runtime store (database, sync endpoint) is planned for later. |
29
- | 4 | Rate limiting | ⬜ | | Research not started. |
29
+ | 4 | Rate limiting | ✅ | [docs/rate-limiting.md](docs/rate-limiting.md) | Released in 0.1.0.pre5 (prerelease). Points per client (query complexity) and operations at the same time, `MemoryStore` and `RedisStore`, the budget in response extensions and headers. A processing-time layer is planned for later. |
30
30
  | 5 | Stable (keyset) connections | ⬜ | | Research not started. |
31
31
  | 6 | Subscriptions over Pusher / Ably | ⬜ | | Research not started. |
32
32
  | 7 | Response caching | ⬜ | | Research not started. |
@@ -53,7 +53,7 @@ GRAPHQL_VERSION="~> 2.4.0" docker compose run --rm test # another graphq
53
53
  Releases are published from GitHub Actions with RubyGems Trusted Publishing (`.github/workflows/release.yml`): no API key is stored and no OTP is asked. Bump `GraphQL::Armory::VERSION` and the CHANGELOG on `main`, then push the matching tag:
54
54
 
55
55
  ```
56
- git tag v0.1.0.pre4 && git push origin v0.1.0.pre4
56
+ git tag v0.1.0.pre5 && git push origin v0.1.0.pre5
57
57
  ```
58
58
 
59
59
  The workflow checks that the tag matches the version, runs the specs and pushes the gem. It only publishes a tag on a commit of `main`. The `release` environment can require a maintainer's approval first (on a public repository, or a private one on a paid GitHub plan).
@@ -103,6 +103,10 @@ return nil if encoder.nil? # nothing in the cha
103
103
  log_legacy_id(id) if encoder == GraphQL::Armory::Encoder::Legacy
104
104
  ```
105
105
 
106
+ ### Testing with IDs
107
+
108
+ `Encoder::Substitute` encodes reversibly without a key and records what it encoded and decoded, for specs of code that uses an encoder; never hand out real IDs with it. `IDs::Controls::Encoder.example` is an Encoder subclass with a fixed test key, and `IDs::Controls::Record.example(id:)` a record to build IDs from. `IDs::Lookup.new`, with nothing wired, hands out no ID and honours none (`Encoder::Null`); `IDs::Lookup.build(encoder:, find:)` takes the same settings as `use`.
109
+
106
110
  ## Reference
107
111
 
108
112
  | Declaration | Meaning |
@@ -136,6 +136,10 @@ Relay's example network layer sends the id as `doc_id`. Pass it yourself: `opera
136
136
 
137
137
  Deploy the server with the new manifest before, or with, the clients that use its new operations: until then they get `Unknown operation id`. Keep old operations in the file while old app versions are still in use.
138
138
 
139
+ ### Testing with persisted queries
140
+
141
+ `PersistedQueries::Store::Substitute.new(*queries)` holds the given queries under their SHA-256, like an Apollo manifest, and records the ids it was asked for (`fetched`). `PersistedQueries::Controls::Operations`, `Query.example` and `Root.example` are a small schema and its operations to test against. The decision itself is `PersistedQueries::Gate`: `Gate.build(store:, strict:)` takes the same settings as `use`, and `gate.(context, query_text, document)` answers `[refusal, document]`. `Gate.new`, with nothing wired, refuses every request (`Store::Null`, strict).
142
+
139
143
  ## Reference
140
144
 
141
145
  `Manifest.new(data, digest: :sha256)`, `Manifest.load(path, digest: :sha256)`
@@ -0,0 +1,293 @@
1
+ # Rate limiting
2
+
3
+ Each client gets a budget of work per minute, measured in what its queries cost, not in how many requests it sends.
4
+
5
+ ## The problem
6
+
7
+ A REST API can limit requests per minute because requests cost roughly the same. GraphQL requests do not:
8
+
9
+ ```graphql
10
+ { me { name } } # almost free
11
+ { invoices(first: 1000) { lines { product { reviews { author { name } } } } } } # seconds of work
12
+ ```
13
+
14
+ A limit of 100 requests per minute lets a client send 100 of the second kind, by mistake or on purpose, and keep your servers busy while everyone else waits. Counting requests does not stop it.
15
+
16
+ ## The solution
17
+
18
+ `GraphQL::Armory::RateLimits::Points` charges every query its **complexity**: the score graphql-ruby already computes from the fields a query asks for, before running it. Each client has a bucket of points that refills over a window:
19
+
20
+ - **Refused before running.** A query that costs more than the client has left is refused before any field resolves, so it costs your servers nothing. It is not charged either.
21
+ - **Predictable for clients.** The refusal says what the query cost, what remains and when to retry, so a client can adapt.
22
+ - **Per client.** Each client key (a user, an API token, an IP) has its own bucket; you can give each key its own limit.
23
+ - **Safe to roll out.** Soft mode, the default, charges and records but refuses nothing, so you can watch real traffic before turning it on.
24
+
25
+ `GraphQL::Armory::RateLimits::Concurrency` adds a second rule: at most N operations running at the same time per client ([Operations at the same time](#operations-at-the-same-time)). Both keep their state in a store: `MemoryStore` for tests and one process, `RedisStore` for production ([Sharing limits between processes](#sharing-limits-between-processes)). Planned: a layer that measures processing time (see [Planned](#planned)).
26
+
27
+ ## How to use it
28
+
29
+ ```ruby
30
+ class AppSchema < GraphQL::Schema
31
+ use GraphQL::Armory::RateLimits::Points,
32
+ store: GraphQL::Armory::RateLimits::MemoryStore.new,
33
+ limit: 1_000, # points
34
+ window: 60, # seconds: the bucket refills 1,000 points every minute
35
+ soft: false # refuse; leave it out (soft mode) while you measure
36
+ end
37
+ ```
38
+
39
+ Name the client in the context:
40
+
41
+ ```ruby
42
+ context = {
43
+ current_user: current_user,
44
+ rate_limit_key: current_user ? "user:#{current_user.id}" : "ip:#{request.remote_ip}"
45
+ }
46
+ AppSchema.execute(params[:query], variables: params[:variables], context: context)
47
+ ```
48
+
49
+ A query without a key raises `RateLimits::ConfigurationError`, so a forgotten controller is caught at once instead of running unlimited. To build the key another way, pass `key: ->(query) { ... }`.
50
+
51
+ ### What a query costs
52
+
53
+ graphql-ruby scores a query before running it:
54
+
55
+ - Every field costs **1 point**, unless you give it another score.
56
+ - Inside a connection, the fields selected under each node cost **page size × their points** (`first:` or `last:`, variables included).
57
+ - Fractional complexities are charged in whole points, rounded up.
58
+
59
+ A worked example:
60
+
61
+ ```graphql
62
+ query {
63
+ viewer { # 1
64
+ invoices(first: 50) { # 1
65
+ nodes { # 1
66
+ number # 50 invoices × 1
67
+ lines(first: 20) { # 50 × 1
68
+ nodes { # 50 × 1
69
+ product # 50 × 20 lines × 1
70
+ comments(first: 10) { # 50 × 20 × 1
71
+ nodes { # 50 × 20 × 1
72
+ body # 50 × 20 × 10 comments × 1
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ followers(first: 10) { # 1
80
+ nodes { # 1
81
+ name # 10 followers × 1
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Calculation:
89
+
90
+ ```
91
+ 3 = viewer, invoices, nodes
92
+ + 50 × 3 = 150 = number, lines, nodes, for each invoice
93
+ + 50 × 20 × 3 = 3,000 = product, comments, nodes, for each line
94
+ + 50 × 20 × 10 × 1 = 10,000 = body, for each comment
95
+ + 2 + 10 × 1 = 12 = followers, nodes, and name for each follower
96
+ = 13,165 points
97
+ ```
98
+
99
+ With `limit: 1_000` this query can never run; asking for `invoices(first: 5)` instead costs about a tenth. The numbers were measured with graphql-ruby's own analysis, so they are exactly what the limiter charges.
100
+
101
+ Give expensive fields a higher score where you define them, so their cost reflects real work:
102
+
103
+ ```ruby
104
+ field :report, ReportType, null: false, complexity: 40 # 40 points instead of 1
105
+ field :create_invoice, mutation: Mutations::CreateInvoice, complexity: 10 # 10 points plus its selections
106
+ ```
107
+
108
+ Three settings make the scores fair. Large public APIs (GitHub, Shopify, see [References](#references)) do the same:
109
+
110
+ - **Paginate every list.** A plain list field (`[Invoice]`) costs its fields **once**, however many items it returns, so 10,000 invoices cost like one. Use connections for anything that can grow. A connection asked for without `first:` or `last:` is sized by the schema's `default_page_size`, or else by `default_max_page_size`. If neither is set, graphql-ruby raises `GraphQL::Error` while scoring the query (a server error, not a refusal), so set one of them. GitHub goes further and requires `first:` or `last:` on every connection.
111
+ - **Cap a single query.** graphql-ruby's `max_complexity 1_000` refuses any query above that score, whatever the client's budget. Shopify caps single queries at 1,000 points.
112
+ - **Make writes cost more.** Give mutations a higher `complexity:`: `complexity: 10` charges 10 points for the mutation field itself, plus the fields it selects. Shopify charges 10 points per mutation by default.
113
+
114
+ See graphql-ruby's [complexity and depth guide](https://graphql-ruby.org/queries/complexity_and_depth) for computed costs.
115
+
116
+ ### When a client runs out
117
+
118
+ The response holds only the refusal:
119
+
120
+ ```json
121
+ {
122
+ "errors": [{
123
+ "message": "Rate limit exceeded: this query costs 40 points and 20 remain (1000 every 60 seconds). Retry in 2 seconds.",
124
+ "extensions": { "code": "RATE_LIMITED", "cost": 40, "remaining": 20, "retryAfter": 2 }
125
+ }]
126
+ }
127
+ ```
128
+
129
+ A query that costs more than the whole limit can never run, and says so: `this query costs 1200 points and the limit is 1000 every 60 seconds, so it can never run.` Its extensions hold `cost` and `limit`.
130
+
131
+ ### Showing clients their budget
132
+
133
+ A client that only learns its budget when it is refused cannot pace itself. GitHub sends the budget in headers on every response; Shopify puts it in the response's `extensions`. You can do either, or both.
134
+
135
+ In the response, with `extensions: true`:
136
+
137
+ ```ruby
138
+ use GraphQL::Armory::RateLimits::Points, store: store, limit: 1_000, window: 60, soft: false, extensions: true
139
+ ```
140
+
141
+ ```json
142
+ {
143
+ "data": { "report": { "total": 120 } },
144
+ "extensions": { "rateLimit": { "cost": 40, "limit": 1000, "remaining": 960, "resetIn": 3 } }
145
+ }
146
+ ```
147
+
148
+ `resetIn` is the number of seconds until the bucket is full again. It is on refused responses too, and absent for a client with no limit. It is off by default, so responses do not change until you turn it on.
149
+
150
+ In HTTP headers, from your controller:
151
+
152
+ ```ruby
153
+ result = AppSchema.execute(params[:query], variables: params[:variables], context: context)
154
+ response.headers.merge!(GraphQL::Armory::RateLimits::Points.headers(result))
155
+ render json: result
156
+ ```
157
+
158
+ That sets `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the bucket is full), or nothing when the query had no limit. It works whether or not `extensions:` is on, and reads each query's own budget, also in a multiplex. The `rateLimit` key of the response extensions belongs to the limiter: do not write it yourself.
159
+
160
+ ### Different limits per client
161
+
162
+ ```ruby
163
+ use GraphQL::Armory::RateLimits::Points, store: store, window: 60,
164
+ limit: ->(key, query) { key.start_with?("partner:") ? 10_000 : 1_000 }
165
+ ```
166
+
167
+ The callable answers a positive Integer, or nil for no limit (for example an internal service). Anything else raises.
168
+
169
+ ### Operations at the same time
170
+
171
+ Points limit how much work a client asks for per minute. They do not stop a client that sends 30 cheap requests at once and occupies every server process while they run: GitHub, for one, also caps concurrent requests. Add `Concurrency` next to `Points`:
172
+
173
+ ```ruby
174
+ class AppSchema < GraphQL::Schema
175
+ use GraphQL::Armory::RateLimits::Concurrency,
176
+ store: store, # the same store as Points
177
+ limit: 5, # operations running at once per client
178
+ soft: false
179
+ end
180
+ ```
181
+
182
+ It uses the same `rate_limit_key`. Before an operation runs it takes one of the client's slots; when the operation ends, it gives the slot back, even if the operation raised or another analyzer refused it. A client whose slots are all taken is refused before any field resolves:
183
+
184
+ ```json
185
+ {
186
+ "errors": [{
187
+ "message": "Too many operations at the same time: 5 are already running for this client (limit 5). Retry when one finishes.",
188
+ "extensions": { "code": "RATE_LIMITED", "limit": 5, "active": 5 }
189
+ }]
190
+ }
191
+ ```
192
+
193
+ A process that crashes never gives its slots back, so each slot expires after `stale_after:` seconds (60 by default). Set it above your longest request timeout. Soft mode, per-key limits (`limit: ->(key, query) { ... }`) and `on_store_error:` work as for `Points`; what happened is in `result.context[:rate_limit_concurrency]` (`{ key:, limit:, active:, soft:, limited: }`).
194
+
195
+ ### Rolling it out
196
+
197
+ 1. Install it in soft mode (leave out `soft:`). Queries run as before; each one records what it cost.
198
+ 2. Send what it recorded to your metrics: `result.context[:rate_limit_points]` is `{ key:, cost:, limit:, remaining:, window:, soft:, limited: }`, and `limited: true` marks the queries that would have been refused.
199
+ 3. When the limits fit your traffic, set `soft: false`.
200
+
201
+ ### Sharing limits between processes
202
+
203
+ `MemoryStore` keeps buckets in one process: with four Puma workers on two servers, a client gets the limit eight times over, and a restart refills every bucket. In production use `RedisStore`, which keeps them in Redis, shared by every process:
204
+
205
+ ```ruby
206
+ # Gemfile: gem "redis"
207
+ RATE_LIMIT_STORE = GraphQL::Armory::RateLimits::RedisStore.new(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
208
+
209
+ class AppSchema < GraphQL::Schema
210
+ use GraphQL::Armory::RateLimits::Points, store: RATE_LIMIT_STORE, limit: 1_000, window: 60, soft: false
211
+ use GraphQL::Armory::RateLimits::Concurrency, store: RATE_LIMIT_STORE, limit: 5, soft: false
212
+ end
213
+ ```
214
+
215
+ - **Atomic.** Each charge, slot taken and slot released is one Lua script, so two requests of the same client never both spend the last points.
216
+ - **One clock.** The time comes from Redis (`TIME`), not from each server, so servers whose clocks drift still agree on when a bucket refills.
217
+ - **Small.** A client costs two keys, `graphql-armory:rate-limits:points:<key>` and `...:slots:<key>`; both expire once they no longer matter (a window, or `stale_after`, after the last use). Pass `prefix:` to share one Redis with other apps.
218
+ - **Fails open.** When Redis is down or slow, the client raises, the query runs, and `on_store_error` is called: make that hook alert. Set the client's timeouts (for example `Redis.new(url: ..., timeout: 0.2)`) so a slow Redis cannot slow every request.
219
+
220
+ The gem does not depend on the redis gem: add it to your Gemfile. A single `Redis` client is safe to share between threads.
221
+
222
+ ### Testing your limits
223
+
224
+ `RateLimits::Store::Substitute` is a `MemoryStore` on a clock your test moves, that records its calls and fails when told, so specs need no mocks and no sleeping:
225
+
226
+ ```ruby
227
+ store = GraphQL::Armory::RateLimits::Store::Substitute.new
228
+ schema = Class.new(AppSchema) { use GraphQL::Armory::RateLimits::Points, store: store, limit: 100, window: 60, soft: false }
229
+
230
+ store.now += 30 # half a window later
231
+ store.fail_with(IOError.new("down"), on: :take) # the next charges raise, as a broken Redis would
232
+ store.answer(:take, [0, 0]) # an answer no real store gives
233
+ store.calls # [[:take, "user:1", { cost: 40, limit: 100, window: 60, force: false }], ...]
234
+ ```
235
+
236
+ `occupy(key)` holds a slot for an operation running elsewhere and `held(key)` counts the slots held. `RateLimits::Controls::Query.example`, `Root.example` and `Context.example` are a small schema and context to test against. `Points.new` and `Concurrency.new`, with nothing wired, refuse every query (`Store::Null`), and check every setting they are given: `soft: true` with no store raises, since it would count nothing; `Points.build(...)` and `Concurrency.build(...)` take the same settings as `use`.
237
+
238
+ ## Reference
239
+
240
+ `use GraphQL::Armory::RateLimits::Points, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false`
241
+
242
+ - `store:` answers `take(key, cost:, limit:, window:, force:)` with `[taken, remaining]`. `MemoryStore.new(clock: ...)` keeps buckets in the process; `RedisStore` shares them (below).
243
+ - `limit:` a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
244
+ - `window:` positive seconds over which `limit` points refill, continuously.
245
+ - `soft:` `true` (default) charges and records without refusing; `false` refuses.
246
+ - `key:` a callable `(query)` answering the client key; by default `context[:rate_limit_key]`. It must be a non-empty String.
247
+ - `on_store_error:` a callable `(error, query)` called when the store raises; the query then runs (fails open). By default it warns.
248
+ - `extensions:` `true` adds `"rateLimit" => { cost, limit, remaining, resetIn }` to every response of a limited client; `false` (default) leaves responses unchanged.
249
+ - `Points.headers(result)` answers the `RateLimit-*` headers for a result, or `{}`.
250
+ - Instrumentation: `context[:rate_limit_points]`.
251
+
252
+ `use GraphQL::Armory::RateLimits::Concurrency, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil`
253
+
254
+ - `store:` answers `acquire(key, token:, limit:, ttl:, force:)` with `[acquired, active]` and `release(key, token:)`. `MemoryStore` does both.
255
+ - `limit:` operations at once per client: a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
256
+ - `stale_after:` positive seconds after which a slot that was never released expires.
257
+ - `soft:`, `key:` and `on_store_error:` as for `Points`. When the store raises on release, `on_store_error` is called and the slot expires after `stale_after`.
258
+ - Instrumentation: `context[:rate_limit_concurrency]`.
259
+
260
+ `GraphQL::Armory::RateLimits::RedisStore.new(redis:, prefix: "graphql-armory:rate-limits:", clock: nil)`
261
+
262
+ - `redis:` a redis-rb client (>= 4.8), or anything answering `eval(script, keys:, argv:)` and `evalsha(sha, keys:, argv:)` the same way. Needs Redis 5 or later. Works with Redis Cluster: each script touches one key.
263
+ - `prefix:` a non-empty String put before every key.
264
+ - `clock:` nil (default) reads Redis's clock; a callable answering seconds replaces it, for tests.
265
+
266
+ Invalid settings raise `RateLimits::ConfigurationError` at boot.
267
+
268
+ ## Guarantees and limits
269
+
270
+ - A query this limiter refuses resolves no field and is not charged. A query that fails validation is not charged. A query refused by another analyzer (`max_depth`, `max_complexity`) is still charged: graphql-ruby runs every analyzer.
271
+ - Each query in a multiplex is charged on its own.
272
+ - `Concurrency` takes one slot per query of a multiplex, and releases them all when the multiplex ends, even when the queries share one context hash. If a release fails, that slot expires after `stale_after`; the others are still released.
273
+ - A query run with its own trace (`context: { trace: ... }`) cannot be released by `Concurrency`, so it raises `ConfigurationError` instead of taking a slot.
274
+ - A resolver that runs another query for the same client (a nested `execute`) takes a second slot while the first is held.
275
+ - A subscription holds a slot only while its first result runs, not while it stays subscribed.
276
+ - When queries of a multiplex share one context hash, `context[:rate_limit_points]` and `context[:rate_limit_concurrency]` hold the last query's record: give each query its own context hash to read every record.
277
+ - The cost is graphql-ruby's estimate before running, not the time a query really takes: a field that is slow but scored 1 costs 1. Score expensive fields, and add the processing-time layer when it ships.
278
+ - Field scores are yours: graphql-ruby treats a negative or NaN complexity as 0, so such a field is free, like `complexity: 0`. A complexity that is nil or a String makes graphql-ruby raise before the limiter runs, so the query does not run.
279
+ - When the store fails, queries run unlimited and `on_store_error` is called. Choose that hook to alert. `RedisStore` also counts a bucket whose fields were changed into something that is not a number as a store failure, rather than reading it as full.
280
+ - `MemoryStore` keeps buckets and slots in one process and forgets them on restart: with several processes, each one gives a client the full limit. It keeps one entry per client key it has seen. Use `RedisStore` in production.
281
+
282
+ ## Planned
283
+
284
+ - **Processing time, a second layer:** a budget of milliseconds per client per window, measured as queries run, for costs the complexity score does not see.
285
+
286
+ ## References
287
+
288
+ The same technique, charging queries a calculated cost against a budget per client, is used by large public GraphQL APIs:
289
+
290
+ - GitHub: [Rate limits and query limits for the GraphQL API](https://docs.github.com/en/graphql/overview/rate-limits-and-query-limits-for-the-graphql-api): a points budget per hour, where connections' `first`/`last` drive the cost, and a `rateLimit` field showing what remains.
291
+ - Shopify: [GraphQL Admin API rate limits](https://shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits): a calculated query cost against a bucket that refills continuously; Shopify also refunds the difference when the real cost is lower than the estimate.
292
+ - Cha et al., [A Principled Approach to GraphQL Query Cost Analysis](https://arxiv.org/abs/2009.05632) (ESEC/FSE 2020, IBM Research): estimating a query's cost statically, before running it, and why naive estimates over- or under-count.
293
+ - graphql-ruby: [Complexity and depth](https://graphql-ruby.org/queries/complexity_and_depth), the cost this limiter charges.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ class Encoder
6
+ # The encoder of an object nobody wired: it hands out no ID and honours none.
7
+ module Null
8
+ def self.encode(_text, nonce: false)
9
+ raise ConfigurationError, "no encoder wired: give an Encoder (or use Encoder.default)"
10
+ end
11
+
12
+ def self.decode(_text, nonce: false)
13
+ raise InvalidError
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ class Encoder
6
+ # Encodes reversibly without a key or any cryptography, records what it encoded and decoded, and fails when
7
+ # told: for tests of the objects that use an encoder. Never use it to hand out real IDs: anyone can forge them.
8
+ class Substitute
9
+ PREFIX = "substitute:"
10
+
11
+ attr_reader :encoded, :decoded
12
+
13
+ def initialize
14
+ @encoded = []
15
+ @decoded = []
16
+ @failure = nil
17
+ end
18
+
19
+ # Raises `error` from every encode and decode until `recover`.
20
+ def fail_with(error)
21
+ @failure = error
22
+ end
23
+
24
+ def recover
25
+ @failure = nil
26
+ end
27
+
28
+ def encode(text, nonce: false)
29
+ @encoded << text.to_s
30
+ raise @failure if @failure
31
+
32
+ PREFIX + URLSafeBase64.encode(text.to_s.b)
33
+ end
34
+
35
+ def decode(text, nonce: false)
36
+ @decoded << text.to_s
37
+ raise @failure if @failure
38
+ raise InvalidError unless text.to_s.start_with?(PREFIX)
39
+
40
+ URLSafeBase64.decode(text.to_s.delete_prefix(PREFIX)).force_encoding(Encoding::UTF_8)
41
+ rescue ArgumentError
42
+ raise InvalidError
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -135,3 +135,5 @@ end
135
135
  require "graphql/armory/encoder/versioned"
136
136
  require "graphql/armory/encoder/legacy"
137
137
  require "graphql/armory/encoder/default"
138
+ require "graphql/armory/encoder/null"
139
+ require "graphql/armory/encoder/substitute"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module IDs
6
+ # Test data for encrypted IDs, reusable by apps and other gems.
7
+ module Controls
8
+ # An Encoder subclass with a fixed test key. Never use its key in production.
9
+ module Encoder
10
+ def self.example(key: "k" * 32)
11
+ Class.new(Armory::Encoder) { key(key) }
12
+ end
13
+ end
14
+
15
+ # A record with an id, as `id_from_object` receives it.
16
+ module Record
17
+ def self.example(id: 1)
18
+ Struct.new(:id).new(id)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -17,7 +17,7 @@ module GraphQL
17
17
  SEPARATOR = "/"
18
18
 
19
19
  def self.use(schema, encoder: nil, find: nil)
20
- lookup = Lookup.new(encoder: encoder || Encoder.default, find: find)
20
+ lookup = Lookup.build(encoder: encoder, find: find)
21
21
  schema.define_singleton_method(:id_from_object) { |object, type, _context| lookup.id_for(object, type) }
22
22
  schema.define_singleton_method(:object_from_id) { |id, context| lookup.object_for(id, context) }
23
23
  end
@@ -26,7 +26,18 @@ module GraphQL
26
26
  class Lookup
27
27
  attr_reader :encoder, :find
28
28
 
29
- def initialize(encoder:, find: nil)
29
+ # Wires the default encoder unless one is given; `use` goes through here.
30
+ def self.build(encoder: nil, find: nil)
31
+ new(encoder: encoder || Encoder.default, find: find)
32
+ end
33
+
34
+ # `new` is inert: with no encoder wired it hands out no ID and honours none. It checks what it is given.
35
+ def initialize(encoder: Encoder::Null, find: nil)
36
+ unless %i[encode decode].all? { |name| encoder.respond_to?(name) }
37
+ raise Encoder::ConfigurationError, "encoder: must answer encode and decode, like an Encoder subclass"
38
+ end
39
+ raise Encoder::ConfigurationError, "find: must be nil or a callable taking the type, the key and the context" unless find.nil? || find.respond_to?(:call)
40
+
30
41
  @encoder = encoder
31
42
  @find = find
32
43
  end
@@ -74,3 +85,5 @@ module GraphQL
74
85
  end
75
86
  end
76
87
  end
88
+
89
+ require "graphql/armory/ids/controls"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Armory
5
+ module PersistedQueries
6
+ # Test data for persisted queries, reusable by apps and other gems.
7
+ module Controls
8
+ # The operations a client build would persist for Query.example.
9
+ module Operations
10
+ def self.invoices = "query Invoices { invoices }"
11
+ def self.greeting = "query Greeting($name: String!) { greeting(name: $name) }"
12
+ end
13
+
14
+ # A Query type with two persisted operations' fields and a `secret` no operation asks for.
15
+ module Query
16
+ def self.example
17
+ Class.new(GraphQL::Schema::Object) do
18
+ graphql_name "Query"
19
+ field :invoices, [String], null: false
20
+ field :greeting, String, null: false do
21
+ argument :name, String
22
+ end
23
+ field :secret, String, null: false
24
+ end
25
+ end
26
+ end
27
+
28
+ # The root object for Query.example; it adds the name of each field it resolves to `resolved`.
29
+ module Root
30
+ def self.example(resolved = [])
31
+ Class.new do
32
+ define_method(:invoices) { resolved << :invoices; ["A-1"] }
33
+ define_method(:greeting) { |name:| resolved << :greeting; "Hello #{name}" }
34
+ define_method(:secret) { resolved << :secret; "s3cr3t" }
35
+ end.new
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module GraphQL
6
+ module Armory
7
+ module PersistedQueries
8
+ # What the plugin fetches operations from: a `Manifest`, or any object answering `fetch(id)`. `Null` and
9
+ # `Substitute` are the inert and the test stand-ins.
10
+ module Store
11
+ # The store of a plugin nobody wired: it holds no operation, so every id is unknown.
12
+ class Null
13
+ def fetch(_id)
14
+ nil
15
+ end
16
+ end
17
+
18
+ # Holds the given queries under the SHA-256 of each, like an Apollo manifest, records the ids it was asked
19
+ # for and fails when told: for tests, instead of hand-written stores.
20
+ class Substitute
21
+ attr_reader :fetched
22
+
23
+ def initialize(*queries)
24
+ @manifest = Manifest.new(queries.to_h { |query| [Digest::SHA256.hexdigest(query), query] })
25
+ @fetched = []
26
+ @failure = nil
27
+ end
28
+
29
+ # Raises `error` from every fetch until `recover`.
30
+ def fail_with(error)
31
+ @failure = error
32
+ end
33
+
34
+ def recover
35
+ @failure = nil
36
+ end
37
+
38
+ def fetch(id)
39
+ @fetched << id
40
+ raise @failure if @failure
41
+
42
+ @manifest.fetch(id)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end