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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +4 -4
- data/docs/encrypted-ids.md +4 -0
- data/docs/persisted-queries.md +4 -0
- data/docs/rate-limiting.md +293 -0
- data/lib/graphql/armory/encoder/null.rb +18 -0
- data/lib/graphql/armory/encoder/substitute.rb +47 -0
- data/lib/graphql/armory/encoder.rb +2 -0
- data/lib/graphql/armory/ids/controls.rb +24 -0
- data/lib/graphql/armory/ids.rb +15 -2
- data/lib/graphql/armory/persisted_queries/controls.rb +41 -0
- data/lib/graphql/armory/persisted_queries/store.rb +48 -0
- data/lib/graphql/armory/persisted_queries.rb +54 -29
- data/lib/graphql/armory/rate_limits/concurrency.rb +127 -0
- data/lib/graphql/armory/rate_limits/controls.rb +40 -0
- data/lib/graphql/armory/rate_limits/memory_store.rb +64 -0
- data/lib/graphql/armory/rate_limits/points.rb +126 -0
- data/lib/graphql/armory/rate_limits/redis_store.rb +133 -0
- data/lib/graphql/armory/rate_limits/store.rb +114 -0
- data/lib/graphql/armory/rate_limits.rb +116 -0
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +1 -0
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 431eab2604e80c473f2037cf636158113056cbd60e9fb408c8f6f91696586b55
|
|
4
|
+
data.tar.gz: aaa7d67cd478e2a0b8faf0b069eeb3e7a8051e2304caa55c8e522ddcc1a121bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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 |
|
|
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.
|
|
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).
|
data/docs/encrypted-ids.md
CHANGED
|
@@ -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 |
|
data/docs/persisted-queries.md
CHANGED
|
@@ -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
|
|
@@ -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
|
data/lib/graphql/armory/ids.rb
CHANGED
|
@@ -17,7 +17,7 @@ module GraphQL
|
|
|
17
17
|
SEPARATOR = "/"
|
|
18
18
|
|
|
19
19
|
def self.use(schema, encoder: nil, find: nil)
|
|
20
|
-
lookup = Lookup.
|
|
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
|
-
|
|
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
|