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
|
@@ -40,20 +40,66 @@ module GraphQL
|
|
|
40
40
|
# `store:` answers `fetch(id)` with an operation (`body` and `document`) or nil: a Manifest, or your own.
|
|
41
41
|
# `strict: true` refuses raw queries unless the context holds `armory_allow_raw_query: true`.
|
|
42
42
|
def self.use(schema, store:, strict: false)
|
|
43
|
-
|
|
44
|
-
raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
|
|
45
|
-
end
|
|
46
|
-
raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
|
|
43
|
+
gate = Gate.build(store: store, strict: strict)
|
|
47
44
|
|
|
48
45
|
if schema.singleton_class.include?(Guard)
|
|
49
46
|
raise ConfigurationError, "#{schema} already uses PersistedQueries (here or in a parent schema): use it once"
|
|
50
47
|
end
|
|
51
48
|
|
|
52
|
-
schema.instance_variable_set(:@armory_persisted_queries,
|
|
49
|
+
schema.instance_variable_set(:@armory_persisted_queries, gate)
|
|
53
50
|
schema.singleton_class.prepend(Guard)
|
|
54
51
|
schema.query_class(schema.query_class)
|
|
55
52
|
end
|
|
56
53
|
|
|
54
|
+
# Decides what a request runs: answers `[refusal, document]`, where a refusal is the error message, a
|
|
55
|
+
# document is the stored operation to run instead of the request's text, and `[nil, nil]` runs the request
|
|
56
|
+
# as it came.
|
|
57
|
+
class Gate
|
|
58
|
+
# Wires the app's store with `use`'s defaults; `use` goes through here.
|
|
59
|
+
def self.build(store:, strict: false)
|
|
60
|
+
new(store: store, strict: strict)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `new` is inert: with no store wired it refuses every request. It checks what it is given, so it never
|
|
64
|
+
# lets raw queries through without a store either.
|
|
65
|
+
def initialize(store: Store::Null.new, strict: true)
|
|
66
|
+
if store.is_a?(Hash) || !store.respond_to?(:fetch)
|
|
67
|
+
raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
|
|
68
|
+
end
|
|
69
|
+
raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
|
|
70
|
+
raise ConfigurationError, "strict: false needs a store: with the Null store only raw queries would run" if !strict && store.is_a?(Store::Null)
|
|
71
|
+
|
|
72
|
+
@store = store
|
|
73
|
+
@strict = strict
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call(context, text, document)
|
|
77
|
+
operation_id = context[:operation_id] if context.respond_to?(:[])
|
|
78
|
+
|
|
79
|
+
if !operation_id.nil?
|
|
80
|
+
operation = operation_id.is_a?(String) ? @store.fetch(operation_id) : nil
|
|
81
|
+
if operation.nil?
|
|
82
|
+
[UNKNOWN_OPERATION, nil]
|
|
83
|
+
elsif document || (text && !same_query?(text, operation))
|
|
84
|
+
[MISMATCH, nil]
|
|
85
|
+
else
|
|
86
|
+
[nil, operation.document]
|
|
87
|
+
end
|
|
88
|
+
elsif @strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
|
|
89
|
+
[RAW_QUERY_REFUSED, nil]
|
|
90
|
+
else
|
|
91
|
+
[nil, nil]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
|
|
98
|
+
def same_query?(text, operation)
|
|
99
|
+
text == operation.body || text == operation.document.to_query_string
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
57
103
|
# Keeps the lookup in every query class the schema uses, including one set after `use` or by a child schema.
|
|
58
104
|
module Guard
|
|
59
105
|
UNSET = Object.new.freeze
|
|
@@ -101,25 +147,9 @@ module GraphQL
|
|
|
101
147
|
# Swaps the request's query for the stored document, or for nothing with an error when it is refused.
|
|
102
148
|
module Lookup
|
|
103
149
|
def initialize(schema, query_string = nil, **options)
|
|
104
|
-
store, strict = schema.armory_persisted_queries
|
|
105
|
-
context = options[:context]
|
|
106
|
-
operation_id = context[:operation_id] if context.respond_to?(:[])
|
|
107
150
|
text = query_string || options[:query]
|
|
108
151
|
text = nil if text == ""
|
|
109
|
-
|
|
110
|
-
refusal, document =
|
|
111
|
-
if !operation_id.nil?
|
|
112
|
-
operation = operation_id.is_a?(String) ? store.fetch(operation_id) : nil
|
|
113
|
-
if operation.nil?
|
|
114
|
-
UNKNOWN_OPERATION
|
|
115
|
-
elsif options[:document] || (text && !same_query?(text, operation))
|
|
116
|
-
MISMATCH
|
|
117
|
-
else
|
|
118
|
-
[nil, operation.document]
|
|
119
|
-
end
|
|
120
|
-
elsif strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
|
|
121
|
-
RAW_QUERY_REFUSED
|
|
122
|
-
end
|
|
152
|
+
refusal, document = schema.armory_persisted_queries.(options[:context], text, options[:document])
|
|
123
153
|
|
|
124
154
|
return super if refusal.nil? && document.nil?
|
|
125
155
|
return super(schema, nil, **options, query: nil, document: document) if refusal.nil?
|
|
@@ -129,16 +159,11 @@ module GraphQL
|
|
|
129
159
|
# with its own validator.
|
|
130
160
|
super(schema, nil, **options, query: nil, document: options[:document] || EMPTY_DOCUMENT, validate: true, static_validator: Refusal.new(schema, refusal))
|
|
131
161
|
end
|
|
132
|
-
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
# Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
|
|
136
|
-
def same_query?(text, operation)
|
|
137
|
-
text == operation.body || text == operation.document.to_query_string
|
|
138
|
-
end
|
|
139
162
|
end
|
|
140
163
|
end
|
|
141
164
|
end
|
|
142
165
|
end
|
|
143
166
|
|
|
144
167
|
require "graphql/armory/persisted_queries/manifest"
|
|
168
|
+
require "graphql/armory/persisted_queries/store"
|
|
169
|
+
require "graphql/armory/persisted_queries/controls"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module RateLimits
|
|
8
|
+
# Lets each client run at most `limit` operations at the same time:
|
|
9
|
+
#
|
|
10
|
+
# use GraphQL::Armory::RateLimits::Concurrency, store: GraphQL::Armory::RateLimits::MemoryStore.new,
|
|
11
|
+
# limit: 5, soft: false
|
|
12
|
+
#
|
|
13
|
+
# A query analyzer takes a slot before the operation runs, or refuses it before any field resolves; a trace
|
|
14
|
+
# releases every slot its multiplex took when the multiplex ends, even when it raises. A slot a crashed
|
|
15
|
+
# process never released expires after `stale_after` seconds. Soft mode (the default) records but refuses
|
|
16
|
+
# nothing. What happened is in `context[:rate_limit_concurrency]`.
|
|
17
|
+
class Concurrency
|
|
18
|
+
CONTEXT_KEY = :rate_limit_concurrency
|
|
19
|
+
NAMESPACE = :graphql_armory
|
|
20
|
+
|
|
21
|
+
def self.use(schema, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
|
|
22
|
+
concurrency = build(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
|
|
23
|
+
schema.query_analyzer(Class.new(Analyzer) { define_method(:concurrency) { concurrency } })
|
|
24
|
+
schema.trace_with(Release)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
|
|
28
|
+
def self.build(store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
|
|
29
|
+
new(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
|
|
33
|
+
# configuration lets a query through either.
|
|
34
|
+
def initialize(store: Store::Null.new, limit: 1, stale_after: 60, soft: false, key: nil, on_store_error: nil)
|
|
35
|
+
unless Settings.store_answers?(store, acquire: %i[token limit ttl force], release: %i[token])
|
|
36
|
+
raise ConfigurationError, "store: must answer acquire(key, token:, limit:, ttl:, force:) and release(key, token:), like a MemoryStore"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@store = store
|
|
40
|
+
@limit = Settings.wired_limit(limit, store)
|
|
41
|
+
@stale_after = Settings.seconds(:stale_after, stale_after)
|
|
42
|
+
@soft = Settings.wired_soft(soft, store)
|
|
43
|
+
@key = Settings.key_reader(key)
|
|
44
|
+
@on_store_error = Settings.store_error_handler(on_store_error)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Takes a slot for the query: the refusal when none is free, or nil to let it run.
|
|
48
|
+
def call(query)
|
|
49
|
+
unless query.current_trace.is_a?(Release)
|
|
50
|
+
raise ConfigurationError, "this query runs with its own trace (context[:trace]), so Concurrency could never release its slot: remove context[:trace] or add trace_with to that trace"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
key = Settings.key_for(@key, query)
|
|
54
|
+
limit = Settings.limit_for(@limit, key, query)
|
|
55
|
+
if limit.nil?
|
|
56
|
+
record(query, key: key, limit: nil, active: nil, limited: false)
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
token = SecureRandom.hex(16)
|
|
61
|
+
answer = begin
|
|
62
|
+
@store.acquire(key, token: token, limit: limit, ttl: @stale_after, force: @soft)
|
|
63
|
+
rescue StandardError => error
|
|
64
|
+
@on_store_error.call(error, query)
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
acquired, active = Settings.checked(answer, :acquire, "the Integer slots held")
|
|
68
|
+
|
|
69
|
+
# Per query, not in the context hash: queries of a multiplex may share one hash.
|
|
70
|
+
query.context.namespace(NAMESPACE)[:concurrency_slot] = [self, key, token] if acquired || @soft
|
|
71
|
+
record(query, key: key, limit: limit, active: active, limited: !acquired)
|
|
72
|
+
refusal(limit, active) unless acquired || @soft
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def release(query, key, token)
|
|
76
|
+
@store.release(key, token: token)
|
|
77
|
+
rescue StandardError => error
|
|
78
|
+
@on_store_error.call(error, query)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def record(query, **info)
|
|
84
|
+
query.context[CONTEXT_KEY] = { **info.slice(:key, :limit, :active), soft: @soft, limited: info[:limited] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refusal(limit, active)
|
|
88
|
+
GraphQL::AnalysisError.new(
|
|
89
|
+
"Too many operations at the same time: #{active} are already running for this client (limit #{limit}). Retry when one finishes.",
|
|
90
|
+
extensions: { "code" => "RATE_LIMITED", "limit" => limit, "active" => active }
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Takes the slot during analysis, before anything runs.
|
|
95
|
+
class Analyzer < GraphQL::Analysis::Analyzer
|
|
96
|
+
def result
|
|
97
|
+
concurrency.(query)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Releases the slots of every query in the multiplex once it is done, whatever happened. Analysis runs
|
|
102
|
+
# inside `execute_multiplex` on both engines, so every slot taken is released here.
|
|
103
|
+
module Release
|
|
104
|
+
def execute_multiplex(multiplex:)
|
|
105
|
+
super
|
|
106
|
+
ensure
|
|
107
|
+
# Every slot is released even if one release raises (an on_store_error that raises); the first such
|
|
108
|
+
# error is raised afterwards.
|
|
109
|
+
failure = nil
|
|
110
|
+
multiplex.queries.each do |query|
|
|
111
|
+
slots = query.context.namespace(NAMESPACE)
|
|
112
|
+
concurrency, key, token = slots.delete(:concurrency_slot)
|
|
113
|
+
next unless concurrency
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
concurrency.release(query, key, token)
|
|
117
|
+
rescue StandardError => error
|
|
118
|
+
failure ||= error
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
raise failure if failure
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module RateLimits
|
|
6
|
+
# Test data for the rate limiters, reusable by apps and other gems.
|
|
7
|
+
module Controls
|
|
8
|
+
# A Query type: `cheap` costs 1 point, `report` costs 40, `broken` raises when resolved.
|
|
9
|
+
module Query
|
|
10
|
+
def self.example
|
|
11
|
+
Class.new(GraphQL::Schema::Object) do
|
|
12
|
+
graphql_name "Query"
|
|
13
|
+
field :cheap, String, null: false
|
|
14
|
+
field :report, String, null: false, complexity: 40
|
|
15
|
+
field :broken, String, null: false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The root object for Query.example; it adds the name of each field it resolves to `resolved`.
|
|
21
|
+
module Root
|
|
22
|
+
def self.example(resolved = [])
|
|
23
|
+
Class.new do
|
|
24
|
+
define_method(:cheap) { resolved << :cheap; "ok" }
|
|
25
|
+
define_method(:report) { resolved << :report; "big" }
|
|
26
|
+
define_method(:broken) { raise IOError, "disk on fire" }
|
|
27
|
+
end.new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A query context naming its client.
|
|
32
|
+
module Context
|
|
33
|
+
def self.example(key: "user:1", **context)
|
|
34
|
+
{ RateLimits::KEY => key, **context }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module RateLimits
|
|
6
|
+
# Buckets kept in this process's memory: for tests, development and single-process servers. Several
|
|
7
|
+
# processes each keep their own buckets, so a client gets the limit once per process; share a store between
|
|
8
|
+
# processes in production.
|
|
9
|
+
class MemoryStore
|
|
10
|
+
def self.monotonic_clock
|
|
11
|
+
-> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(clock: self.class.monotonic_clock)
|
|
15
|
+
@clock = clock
|
|
16
|
+
@buckets = {}
|
|
17
|
+
@slots = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Token bucket: holds up to `limit` points and refills `limit` points every `window` seconds, continuously.
|
|
22
|
+
# Takes `cost` points when they are there; with `force: true` takes them anyway, down to zero. Answers
|
|
23
|
+
# whether they were there and the whole points left.
|
|
24
|
+
def take(key, cost:, limit:, window:, force: false)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
now = @clock.call
|
|
27
|
+
level, at = @buckets.fetch(key) { [limit, now] }
|
|
28
|
+
level = [level + ([now - at, 0].max * limit / window.to_f), limit].min
|
|
29
|
+
|
|
30
|
+
taken = cost <= level
|
|
31
|
+
level = [level - cost, 0].max if taken || force
|
|
32
|
+
@buckets[key] = [level, [now, at].max]
|
|
33
|
+
|
|
34
|
+
[taken, level.floor]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Slots for operations running at the same time: at most `limit` per key, each held by a token until
|
|
39
|
+
# released or until `ttl` seconds pass (a process that crashed never releases). Takes a slot when one is
|
|
40
|
+
# free; with `force: true` takes it anyway. Answers whether one was free and the slots now held.
|
|
41
|
+
def acquire(key, token:, limit:, ttl:, force: false)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
now = @clock.call
|
|
44
|
+
slots = (@slots[key] ||= {})
|
|
45
|
+
slots.delete_if { |_token, expires_at| expires_at <= now }
|
|
46
|
+
|
|
47
|
+
acquired = slots.size < limit
|
|
48
|
+
slots[token] = now + ttl if acquired || force
|
|
49
|
+
|
|
50
|
+
[acquired, slots.size]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def release(key, token:)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
slots = @slots[key] or return
|
|
57
|
+
slots.delete(token)
|
|
58
|
+
@slots.delete(key) if slots.empty?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module RateLimits
|
|
6
|
+
# Charges each query its complexity, as graphql-ruby computes it before running it, against a bucket of
|
|
7
|
+
# points per client that refills over a window:
|
|
8
|
+
#
|
|
9
|
+
# use GraphQL::Armory::RateLimits::Points, store: GraphQL::Armory::RateLimits::MemoryStore.new,
|
|
10
|
+
# limit: 1_000, window: 60, soft: false
|
|
11
|
+
#
|
|
12
|
+
# A query that costs more than the client has left is refused before any field resolves, and charged
|
|
13
|
+
# nothing. Soft mode (the default) charges and records but refuses nothing. What happened is in
|
|
14
|
+
# `context[:rate_limit_points]`.
|
|
15
|
+
class Points
|
|
16
|
+
CONTEXT_KEY = :rate_limit_points
|
|
17
|
+
NAMESPACE = :graphql_armory
|
|
18
|
+
|
|
19
|
+
def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
|
|
20
|
+
points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
|
|
21
|
+
schema.query_analyzer(Class.new(Analyzer) { define_method(:points) { points } })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# HTTP headers for the budget a result recorded: `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`
|
|
25
|
+
# (seconds until the bucket is full again). Empty when the query had no limit.
|
|
26
|
+
def self.headers(result)
|
|
27
|
+
info = result.context.namespace(NAMESPACE)[:points]
|
|
28
|
+
return {} unless info && info[:limit]
|
|
29
|
+
|
|
30
|
+
{ "RateLimit-Limit" => info[:limit].to_s, "RateLimit-Remaining" => info[:remaining].to_s, "RateLimit-Reset" => reset_in(info).to_s }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.reset_in(info)
|
|
34
|
+
((info[:limit] - info[:remaining]) * info[:window] / info[:limit].to_f).ceil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
|
|
38
|
+
def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
|
|
39
|
+
new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
|
|
43
|
+
# configuration lets a query through either.
|
|
44
|
+
def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, extensions: false)
|
|
45
|
+
unless Settings.store_answers?(store, take: %i[cost limit window force])
|
|
46
|
+
raise ConfigurationError, "store: must answer take(key, cost:, limit:, window:, force:), like a MemoryStore"
|
|
47
|
+
end
|
|
48
|
+
raise ConfigurationError, "extensions: must be true or false, got #{extensions.inspect}" unless extensions == true || extensions == false
|
|
49
|
+
|
|
50
|
+
@store = store
|
|
51
|
+
@limit = Settings.wired_limit(limit, store)
|
|
52
|
+
@window = Settings.seconds(:window, window)
|
|
53
|
+
@soft = Settings.wired_soft(soft, store)
|
|
54
|
+
@key = Settings.key_reader(key)
|
|
55
|
+
@on_store_error = Settings.store_error_handler(on_store_error)
|
|
56
|
+
@extensions = extensions
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The refusal for a query that costs `cost`, or nil to let it run.
|
|
60
|
+
def call(query, cost)
|
|
61
|
+
key = Settings.key_for(@key, query)
|
|
62
|
+
limit = Settings.limit_for(@limit, key, query)
|
|
63
|
+
if limit.nil?
|
|
64
|
+
record(query, key: key, cost: cost, limit: nil, remaining: nil, limited: false)
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
answer = begin
|
|
69
|
+
@store.take(key, cost: cost, limit: limit, window: @window, force: @soft)
|
|
70
|
+
rescue StandardError => error
|
|
71
|
+
# No record: a context hash shared with an earlier query must not keep its numbers.
|
|
72
|
+
query.context[CONTEXT_KEY] = nil
|
|
73
|
+
@on_store_error.call(error, query)
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
taken, remaining = Settings.checked(answer, :take, "the Integer points left")
|
|
77
|
+
|
|
78
|
+
info = record(query, key: key, cost: cost, limit: limit, remaining: remaining, limited: !taken)
|
|
79
|
+
if @extensions
|
|
80
|
+
query.context.response_extensions["rateLimit"] =
|
|
81
|
+
{ "cost" => cost, "limit" => limit, "remaining" => remaining, "resetIn" => self.class.reset_in(info) }
|
|
82
|
+
end
|
|
83
|
+
refusal(cost, limit, remaining) unless taken || @soft
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# In the context hash for instrumentation, and per query for `headers`: queries of a multiplex may share one
|
|
89
|
+
# context hash, where the last query's record wins.
|
|
90
|
+
def record(query, **info)
|
|
91
|
+
info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited] }
|
|
92
|
+
query.context.namespace(NAMESPACE)[:points] = info
|
|
93
|
+
query.context[CONTEXT_KEY] = info
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def refusal(cost, limit, remaining)
|
|
97
|
+
if cost > limit
|
|
98
|
+
return GraphQL::AnalysisError.new(
|
|
99
|
+
"Rate limit exceeded: this query costs #{cost} points and the limit is #{limit} every #{@window} seconds, so it can never run.",
|
|
100
|
+
extensions: { "code" => "RATE_LIMITED", "cost" => cost, "limit" => limit }
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
retry_after = ((cost - remaining) * @window / limit.to_f).ceil
|
|
105
|
+
GraphQL::AnalysisError.new(
|
|
106
|
+
"Rate limit exceeded: this query costs #{cost} points and #{remaining} remain (#{limit} every #{@window} seconds). Retry in #{retry_after} seconds.",
|
|
107
|
+
extensions: { "code" => "RATE_LIMITED", "cost" => cost, "remaining" => remaining, "retryAfter" => retry_after }
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# graphql-ruby's own complexity analysis, then the charge.
|
|
112
|
+
class Analyzer < GraphQL::Analysis::QueryComplexity
|
|
113
|
+
def result
|
|
114
|
+
cost = super
|
|
115
|
+
unless cost.is_a?(Numeric) && cost.finite? && !cost.negative?
|
|
116
|
+
raise ConfigurationError, "the query complexity is #{cost.inspect}: field complexities must be numbers of zero or more"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fractional complexities (`complexity: 0.5`) are charged whole points, rounded up.
|
|
120
|
+
points.(query, cost.ceil)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module RateLimits
|
|
8
|
+
# Buckets and slots kept in Redis, shared by every process of the app: the store for production.
|
|
9
|
+
#
|
|
10
|
+
# store = GraphQL::Armory::RateLimits::RedisStore.new(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
|
|
11
|
+
#
|
|
12
|
+
# Each change is one Lua script, so concurrent requests never race, and the time comes from Redis's own clock,
|
|
13
|
+
# so processes on different machines agree on it. Keys live under `prefix` and expire once they no longer
|
|
14
|
+
# matter. `redis:` is a redis-rb client (>= 4.8), or anything answering `eval` and `evalsha` the same way;
|
|
15
|
+
# its errors reach the limiters, which let the query through and call `on_store_error`. This gem does not
|
|
16
|
+
# depend on the redis gem: the app adds it.
|
|
17
|
+
class RedisStore
|
|
18
|
+
DEFAULT_PREFIX = "graphql-armory:rate-limits:"
|
|
19
|
+
|
|
20
|
+
# The time in seconds: the one given by the caller (tests), or Redis's own clock.
|
|
21
|
+
CLOCK = <<~LUA
|
|
22
|
+
local function clock(given)
|
|
23
|
+
if given ~= "" then return tonumber(given) end
|
|
24
|
+
local time = redis.call("TIME")
|
|
25
|
+
return tonumber(time[1]) + tonumber(time[2]) / 1000000
|
|
26
|
+
end
|
|
27
|
+
LUA
|
|
28
|
+
|
|
29
|
+
# Same token bucket as MemoryStore#take. The bucket expires once a whole window has passed: it is full
|
|
30
|
+
# again by then, the same as no bucket at all. An expiry is only ever pushed later, never brought closer, so
|
|
31
|
+
# a limiter with a shorter window sharing the key cannot cut it short. The points left go back as a string:
|
|
32
|
+
# Redis would truncate a Lua number past 2**63. A bucket whose fields are not numbers is an error, so the
|
|
33
|
+
# limiter calls on_store_error instead of reading it as full.
|
|
34
|
+
TAKE = <<~LUA
|
|
35
|
+
#{CLOCK}
|
|
36
|
+
local cost, limit, window = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
|
|
37
|
+
local force, now = ARGV[4] == "1", clock(ARGV[5])
|
|
38
|
+
local state = redis.call("HMGET", KEYS[1], "level", "at")
|
|
39
|
+
local level, at = tonumber(state[1]), tonumber(state[2])
|
|
40
|
+
if not (state[1] or state[2]) then
|
|
41
|
+
level, at = limit, now
|
|
42
|
+
elseif not (level and at) or level ~= level or at ~= at or level < 0 or level == math.huge or math.abs(at) == math.huge then
|
|
43
|
+
return redis.error_reply("graphql-armory: corrupt rate limit bucket at " .. KEYS[1])
|
|
44
|
+
end
|
|
45
|
+
level = math.min(level + math.max(now - at, 0) * limit / window, limit)
|
|
46
|
+
|
|
47
|
+
local taken = cost <= level
|
|
48
|
+
if taken or force then level = math.max(level - cost, 0) end
|
|
49
|
+
redis.call("HSET", KEYS[1], "level", string.format("%.17g", level), "at", string.format("%.17g", math.max(now, at)))
|
|
50
|
+
local keep = math.ceil(window * 1000) + 1000
|
|
51
|
+
if redis.call("PTTL", KEYS[1]) < keep then redis.call("PEXPIRE", KEYS[1], keep) end
|
|
52
|
+
|
|
53
|
+
return { taken and 1 or 0, string.format("%.0f", math.floor(level)) }
|
|
54
|
+
LUA
|
|
55
|
+
|
|
56
|
+
# Same slots as MemoryStore#acquire: a sorted set of tokens scored by when they expire. The key lives as long
|
|
57
|
+
# as its latest slot, whatever ttl each acquire passed.
|
|
58
|
+
ACQUIRE = <<~LUA
|
|
59
|
+
#{CLOCK}
|
|
60
|
+
local token, limit, ttl = ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
|
|
61
|
+
local force, now = ARGV[4] == "1", clock(ARGV[5])
|
|
62
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", string.format("%.17g", now))
|
|
63
|
+
|
|
64
|
+
local acquired = redis.call("ZCARD", KEYS[1]) < limit
|
|
65
|
+
if acquired or force then
|
|
66
|
+
redis.call("ZADD", KEYS[1], string.format("%.17g", now + ttl), token)
|
|
67
|
+
local latest = tonumber(redis.call("ZRANGE", KEYS[1], -1, -1, "WITHSCORES")[2])
|
|
68
|
+
redis.call("PEXPIRE", KEYS[1], math.ceil((latest - now) * 1000) + 1000)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return { acquired and 1 or 0, redis.call("ZCARD", KEYS[1]) }
|
|
72
|
+
LUA
|
|
73
|
+
|
|
74
|
+
RELEASE = <<~LUA
|
|
75
|
+
return redis.call("ZREM", KEYS[1], ARGV[1])
|
|
76
|
+
LUA
|
|
77
|
+
|
|
78
|
+
def initialize(redis:, prefix: DEFAULT_PREFIX, clock: nil)
|
|
79
|
+
unless redis.respond_to?(:eval) && redis.respond_to?(:evalsha)
|
|
80
|
+
raise ConfigurationError, "redis: must be a Redis client that runs scripts (eval and evalsha), like Redis.new from the redis gem"
|
|
81
|
+
end
|
|
82
|
+
raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
|
|
83
|
+
raise ConfigurationError, "clock: must be nil (Redis's clock) or answer call with the time in seconds" unless clock.nil? || clock.respond_to?(:call)
|
|
84
|
+
|
|
85
|
+
@redis = redis
|
|
86
|
+
@prefix = prefix
|
|
87
|
+
@clock = clock
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def take(key, cost:, limit:, window:, force: false)
|
|
91
|
+
taken, level = script(TAKE, "#{@prefix}points:#{key}", cost, limit, seconds(window), flag(force), now)
|
|
92
|
+
[taken == 1, Integer(level, 10)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def acquire(key, token:, limit:, ttl:, force: false)
|
|
96
|
+
acquired, held = script(ACQUIRE, "#{@prefix}slots:#{key}", token, limit, seconds(ttl), flag(force), now)
|
|
97
|
+
[acquired == 1, held]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def release(key, token:)
|
|
101
|
+
script(RELEASE, "#{@prefix}slots:#{key}", token)
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def flag(value)
|
|
108
|
+
value ? "1" : "0"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Lua reads decimal numbers only: not "1/2" (a Rational) nor a Time.
|
|
112
|
+
def seconds(value)
|
|
113
|
+
Float(value).to_s
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def now
|
|
117
|
+
@clock ? seconds(@clock.call.to_f) : ""
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# EVALSHA sends only the script's digest; Redis forgets scripts on restart or SCRIPT FLUSH, and then EVAL
|
|
121
|
+
# sends it whole once more.
|
|
122
|
+
def script(source, key, *argv)
|
|
123
|
+
argv = argv.map(&:to_s)
|
|
124
|
+
@redis.evalsha(Digest::SHA1.hexdigest(source), keys: [key], argv: argv)
|
|
125
|
+
rescue StandardError => error
|
|
126
|
+
raise unless error.message.start_with?("NOSCRIPT")
|
|
127
|
+
|
|
128
|
+
@redis.eval(source, keys: [key], argv: argv)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|