gitlab-labkit 2.8.1 → 4.6.0
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/.gitlab/CODEOWNERS +1 -1
- data/.gitlab-ci-asdf-versions.yml +1 -1
- data/.gitlab-ci.yml +3 -3
- data/.pre-commit-config.yaml +1 -1
- data/.tool-versions +1 -1
- data/CODEOWNERS +1 -1
- data/lib/labkit/fields.rb +8 -1
- data/lib/labkit/rate_limit/README.md +279 -74
- data/lib/labkit/rate_limit/evaluator.rb +339 -80
- data/lib/labkit/rate_limit/identifier.rb +26 -1
- data/lib/labkit/rate_limit/limiter.rb +24 -3
- data/lib/labkit/rate_limit/matcher.rb +69 -9
- data/lib/labkit/rate_limit/metrics.rb +62 -3
- data/lib/labkit/rate_limit/result.rb +133 -19
- data/lib/labkit/rate_limit/rule.rb +42 -11
- data/lib/labkit/rate_limit.rb +2 -1
- metadata +2 -2
|
@@ -9,9 +9,16 @@ module Labkit
|
|
|
9
9
|
# @api private
|
|
10
10
|
class Evaluator
|
|
11
11
|
REDIS_KEY_PREFIX = "labkit:rl"
|
|
12
|
+
BAN_KEY_SUFFIX = ":ban"
|
|
12
13
|
CHAR_VALUE_MAX_LENGTH = 200
|
|
13
14
|
MISSING_VALUE_SENTINEL = "_unknown_"
|
|
14
15
|
|
|
16
|
+
# Carries the rule in flight out of the rule loop and into the rescue in
|
|
17
|
+
# #check/#peek, where the error is seen but the rule is out of scope.
|
|
18
|
+
# Per call, not instance state: one Evaluator serves concurrent requests.
|
|
19
|
+
# Only read while an exception unwinds; nil means no rule was in flight.
|
|
20
|
+
RuleCursor = Struct.new(:rule)
|
|
21
|
+
|
|
15
22
|
# Atomic increment-with-TTL Lua script. The whole script runs as one
|
|
16
23
|
# operation from Redis's perspective, so there is no window between
|
|
17
24
|
# the increment and EXPIRE that can leak a key without TTL.
|
|
@@ -22,40 +29,86 @@ module Labkit
|
|
|
22
29
|
# Redis treats the result as a no-op on the stored value while
|
|
23
30
|
# still observing the post-state count and TTL we return.
|
|
24
31
|
#
|
|
25
|
-
#
|
|
32
|
+
# ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
|
|
26
33
|
# The -1 case shouldn't arise with the atomic script, but self-healing
|
|
27
34
|
# recovers keys left without TTL by any prior bug.
|
|
28
35
|
INCR_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
29
36
|
local ttl = ARGV[1]
|
|
30
37
|
local cost = tonumber(ARGV[2])
|
|
31
|
-
local ttl_before = redis.call('TTL', KEYS[1])
|
|
32
38
|
|
|
33
39
|
local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
|
|
34
|
-
|
|
40
|
+
local ttl_after = redis.call('TTL', KEYS[1])
|
|
41
|
+
if ttl_after < 0 then
|
|
35
42
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
43
|
+
ttl_after = tonumber(ttl)
|
|
36
44
|
end
|
|
37
45
|
|
|
38
|
-
return {count,
|
|
46
|
+
return {count, ttl_after}
|
|
39
47
|
LUA
|
|
40
48
|
|
|
41
49
|
# Atomic SADD + SCARD + conditional EXPIRE. SET-cardinality counterpart
|
|
42
|
-
# of INCR_SCRIPT; same shape (read TTL,
|
|
50
|
+
# of INCR_SCRIPT; same shape (mutate, read TTL, set TTL when missing,
|
|
43
51
|
# return post-state {count, TTL}). count is SCARD, not the SADD return.
|
|
44
52
|
#
|
|
45
|
-
#
|
|
53
|
+
# ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
|
|
46
54
|
# so this also self-heals orphan keys left without TTL.
|
|
47
55
|
SADD_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
48
56
|
local ttl = ARGV[1]
|
|
49
57
|
local member = ARGV[2]
|
|
50
|
-
local ttl_before = redis.call('TTL', KEYS[1])
|
|
51
58
|
|
|
52
59
|
redis.call('SADD', KEYS[1], member)
|
|
53
60
|
local count = redis.call('SCARD', KEYS[1])
|
|
54
|
-
|
|
61
|
+
local ttl_after = redis.call('TTL', KEYS[1])
|
|
62
|
+
if ttl_after < 0 then
|
|
55
63
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
64
|
+
ttl_after = tonumber(ttl)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return {count, ttl_after}
|
|
68
|
+
LUA
|
|
69
|
+
|
|
70
|
+
# Atomic ban check + INCR + conditional ban write, for rules carrying
|
|
71
|
+
# ban_for.
|
|
72
|
+
#
|
|
73
|
+
# An active ban short-circuits before the increment, so a banned caller
|
|
74
|
+
# cannot extend its own window, matching Rack::Attack::Allow2Ban. This
|
|
75
|
+
# applies to :log rules too: a shadow that kept counting through its own
|
|
76
|
+
# ban would report more than enforcement would produce. The
|
|
77
|
+
# counter and the ban must be read and written together or a concurrent
|
|
78
|
+
# check can miss the threshold crossing.
|
|
79
|
+
#
|
|
80
|
+
# Returns {count, counter TTL, ban TTL}, where a ban TTL below zero means
|
|
81
|
+
# not banned.
|
|
82
|
+
BAN_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
83
|
+
local rule_counter_key = KEYS[1]
|
|
84
|
+
local ban_key = KEYS[2]
|
|
85
|
+
|
|
86
|
+
local ttl = ARGV[1]
|
|
87
|
+
local cost = tonumber(ARGV[2])
|
|
88
|
+
local limit = tonumber(ARGV[3])
|
|
89
|
+
local ban_for = tonumber(ARGV[4])
|
|
90
|
+
|
|
91
|
+
-- TTL answers both questions at once: >= 0 means the ban key exists
|
|
92
|
+
-- and tells us how long is left, so no separate EXISTS is needed.
|
|
93
|
+
local ban_ttl = redis.call('TTL', ban_key)
|
|
94
|
+
if ban_ttl >= 0 then
|
|
95
|
+
local existing = redis.call('GET', rule_counter_key)
|
|
96
|
+
return {existing or '0', redis.call('TTL', rule_counter_key), ban_ttl}
|
|
56
97
|
end
|
|
57
98
|
|
|
58
|
-
|
|
99
|
+
local count = redis.call('INCRBYFLOAT', rule_counter_key, cost)
|
|
100
|
+
local ttl_after = redis.call('TTL', rule_counter_key)
|
|
101
|
+
if ttl_after < 0 then
|
|
102
|
+
redis.call('EXPIRE', rule_counter_key, ttl)
|
|
103
|
+
ttl_after = tonumber(ttl)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if tonumber(count) > limit then
|
|
107
|
+
redis.call('SET', ban_key, '1', 'EX', ban_for)
|
|
108
|
+
return {count, ttl_after, ban_for}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
return {count, ttl_after, -1}
|
|
59
112
|
LUA
|
|
60
113
|
|
|
61
114
|
def initialize(name:, rules:, redis:, logger:)
|
|
@@ -66,99 +119,185 @@ module Labkit
|
|
|
66
119
|
end
|
|
67
120
|
|
|
68
121
|
def check(identifier, cost: 1, rule_context: nil)
|
|
69
|
-
|
|
122
|
+
cursor = RuleCursor.new
|
|
123
|
+
result = check_rules(identifier, cost, rule_context, cursor)
|
|
70
124
|
rescue StandardError => e
|
|
71
125
|
# Intentionally broad: fail-open applies to any unexpected error (network,
|
|
72
126
|
# timeout, OOM) not only Redis protocol errors.
|
|
73
127
|
report_error_metrics
|
|
74
|
-
log_error(e, identifier)
|
|
75
|
-
Result.
|
|
128
|
+
log_error(e, identifier, cursor.rule)
|
|
129
|
+
result = Result.error
|
|
130
|
+
ensure
|
|
131
|
+
# StandardError-safe emission, so it cannot mask a propagating error.
|
|
132
|
+
# result is nil when a non-StandardError unwinds: emit nothing then.
|
|
133
|
+
report_check_metrics(result) if result
|
|
76
134
|
end
|
|
77
135
|
|
|
78
136
|
# Read-without-increment counterpart to {#check}. Same matching and Result
|
|
79
137
|
# shape; the underlying Redis counter is not mutated and the TTL is not
|
|
80
138
|
# extended. A missing Redis key is treated as count=0 (matched, not exceeded).
|
|
81
139
|
def peek(identifier, rule_context: nil)
|
|
82
|
-
|
|
140
|
+
cursor = RuleCursor.new
|
|
141
|
+
peek_rules(identifier, rule_context, cursor)
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
report_error_metrics
|
|
144
|
+
log_error(e, identifier, cursor.rule)
|
|
145
|
+
Result.error
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Deletes this limiter's counters, and any bans, for one identifier.
|
|
149
|
+
#
|
|
150
|
+
# Every rule is cleared, matched or not: a caller clearing state after a
|
|
151
|
+
# success knows the identifier, not which rules happened to match on the
|
|
152
|
+
# way in. Rules whose keys do not exist are a no-op.
|
|
153
|
+
#
|
|
154
|
+
# Fails open like {#check}: an unreachable Redis leaves the state to
|
|
155
|
+
# expire on its own rather than raising into the caller.
|
|
156
|
+
def clear(identifier)
|
|
157
|
+
# Counted as we go, so a connection lost mid-loop still reports the keys
|
|
158
|
+
# that did go rather than claiming nothing was cleared.
|
|
159
|
+
removed = 0
|
|
160
|
+
|
|
161
|
+
key_groups = @rules.filter_map do |rule|
|
|
162
|
+
next if rule.action == :skip
|
|
163
|
+
|
|
164
|
+
[build_redis_key(rule, identifier), build_redis_key(rule, identifier, BAN_KEY_SUFFIX)]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return 0 if key_groups.empty?
|
|
168
|
+
|
|
169
|
+
# One DEL per rule. The hash tag holds the rule name, so a rule's own
|
|
170
|
+
# keys share a slot but two rules do not, and Redis Cluster rejects a
|
|
171
|
+
# DEL spanning slots.
|
|
172
|
+
@redis.with do |conn|
|
|
173
|
+
key_groups.each { |group| removed += conn.del(*group) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
removed
|
|
83
177
|
rescue StandardError => e
|
|
84
178
|
report_error_metrics
|
|
85
|
-
log_error(e, identifier)
|
|
86
|
-
|
|
179
|
+
log_error(e, identifier, nil)
|
|
180
|
+
removed
|
|
87
181
|
end
|
|
88
182
|
|
|
89
183
|
private
|
|
90
184
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
185
|
+
# Every rule that matches is evaluated and counted; matching does not
|
|
186
|
+
# short-circuit the loop. Exactly two cases terminate it early:
|
|
187
|
+
#
|
|
188
|
+
# - :skip terminates on match without touching Redis. No counter is
|
|
189
|
+
# incremented, so the branch sits before the count_distinct check
|
|
190
|
+
# (identifier completeness is irrelevant to a rule that builds no key).
|
|
191
|
+
# rule_evaluations_total still increments so the bypass stays observable.
|
|
192
|
+
# - a :limit rule over its limit terminates, because the request is
|
|
193
|
+
# rejected and later rules cannot change that. Rules declared after it
|
|
194
|
+
# are neither counted nor evaluated, so a blocked request debits every
|
|
195
|
+
# rule up to and including the one that blocked, and none after it.
|
|
196
|
+
# A :limit rule carrying ban_for counts as over its limit for as long
|
|
197
|
+
# as its ban holds, so it terminates for the whole ban. The same rule
|
|
198
|
+
# with action :log does identical accounting and terminates nothing,
|
|
199
|
+
# which is what makes it a faithful shadow.
|
|
200
|
+
#
|
|
201
|
+
# Every other matched rule - :log, and :limit while under its limit - is
|
|
202
|
+
# counted and collected into the returned Result, which reports the
|
|
203
|
+
# most-constraining evaluation (ranking lives in Result::Evaluation#<=>).
|
|
204
|
+
# cost is therefore debited from every matching rule, not just the first.
|
|
93
205
|
#
|
|
94
|
-
# :
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# calls_total still increments so the bypass stays observable.
|
|
206
|
+
# Metrics: each evaluated rule emits rule_evaluations_total (plus the
|
|
207
|
+
# deprecated per-rule calls_total); the per-check checks_total is
|
|
208
|
+
# emitted once in #check. Full contract in the README's Metrics section.
|
|
98
209
|
#
|
|
99
210
|
# SET-mode rules (rule.count_distinct set) that match but whose identifier
|
|
100
|
-
# is missing the count_distinct key fail open + log + bump errors_total
|
|
101
|
-
# the loop continues to the next rule (the rule is
|
|
102
|
-
# rather than aborting the whole evaluation).
|
|
103
|
-
|
|
211
|
+
# is missing the count_distinct key fail open + log + bump errors_total +
|
|
212
|
+
# flag the Result, and the loop continues to the next rule (the rule is
|
|
213
|
+
# treated as not applicable rather than aborting the whole evaluation).
|
|
214
|
+
#
|
|
215
|
+
# Error handling stays whole-check (see #check): a raise part-way through
|
|
216
|
+
# discards the results of the rules already evaluated, even though their
|
|
217
|
+
# counters were incremented. No blocking verdict is lost that way - a
|
|
218
|
+
# :limit rule over its limit returns before any later rule can raise -
|
|
219
|
+
# but a rule declared after the failing one loses its chance to block.
|
|
220
|
+
# The request is counted and allowed. +cursor+ is set before the match, so
|
|
221
|
+
# a raise from the match itself (e.g. Regexp::TimeoutError) is attributed.
|
|
222
|
+
def check_rules(identifier, cost, rule_context, cursor)
|
|
223
|
+
result = Result.new
|
|
224
|
+
|
|
104
225
|
@rules.each do |rule|
|
|
226
|
+
cursor.rule = rule
|
|
227
|
+
|
|
105
228
|
next unless rule_matches?(rule, identifier)
|
|
106
229
|
|
|
107
230
|
if rule.action == :skip
|
|
108
231
|
report_skipped_metrics(rule)
|
|
109
|
-
return
|
|
232
|
+
return result.skip!(rule)
|
|
110
233
|
end
|
|
111
234
|
|
|
112
235
|
if rule.count_distinct && missing_count_distinct_value?(rule, identifier)
|
|
113
236
|
log_missing_count_distinct(rule, identifier)
|
|
114
237
|
report_error_metrics
|
|
238
|
+
result.degraded!
|
|
115
239
|
next
|
|
116
240
|
end
|
|
117
241
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
242
|
+
evaluation = evaluate_rule(rule, identifier, cost, rule_context)
|
|
243
|
+
result.add_evaluation(evaluation)
|
|
244
|
+
report_evaluation_metrics(evaluation)
|
|
245
|
+
return result if result.block?
|
|
121
246
|
end
|
|
122
247
|
|
|
123
|
-
|
|
124
|
-
|
|
248
|
+
# The loop is done, so anything raised from here on belongs to no rule.
|
|
249
|
+
cursor.rule = nil
|
|
250
|
+
|
|
251
|
+
report_unmatched_metrics unless result.matched?
|
|
252
|
+
result
|
|
253
|
+
rescue StandardError => e # binds e for the ensure
|
|
254
|
+
raise
|
|
255
|
+
ensure
|
|
256
|
+
# Keep the rule attributed while an exception unwinds; clear it on
|
|
257
|
+
# every normal exit (early returns included).
|
|
258
|
+
cursor.rule = nil unless e
|
|
125
259
|
end
|
|
126
260
|
|
|
127
|
-
# Mirror of check_rules without metrics
|
|
128
|
-
#
|
|
261
|
+
# Mirror of check_rules without metrics or writes. :log rules are read
|
|
262
|
+
# here too: check can report a :log rule's evaluation, so excluding them
|
|
263
|
+
# would make peek answer a different question than check.
|
|
129
264
|
#
|
|
130
265
|
# peek does not need the count_distinct identifier key - it reads SCARD on
|
|
131
266
|
# the rule-keyed compound key, which contains the cardinality across all
|
|
132
267
|
# members. So missing-key fail-open does not apply here.
|
|
133
|
-
def peek_rules(identifier, rule_context)
|
|
268
|
+
def peek_rules(identifier, rule_context, cursor)
|
|
269
|
+
result = Result.new
|
|
270
|
+
|
|
134
271
|
@rules.each do |rule|
|
|
135
|
-
|
|
272
|
+
cursor.rule = rule
|
|
273
|
+
|
|
136
274
|
next unless rule_matches?(rule, identifier)
|
|
137
275
|
|
|
138
|
-
return
|
|
276
|
+
return result.skip!(rule) if rule.action == :skip
|
|
139
277
|
|
|
140
|
-
|
|
278
|
+
result.add_evaluation(peek_rule(rule, identifier, rule_context))
|
|
279
|
+
return result if result.block?
|
|
141
280
|
end
|
|
142
281
|
|
|
143
|
-
|
|
282
|
+
result
|
|
283
|
+
rescue StandardError => e # binds e for the ensure
|
|
284
|
+
raise
|
|
285
|
+
ensure
|
|
286
|
+
cursor.rule = nil unless e
|
|
144
287
|
end
|
|
145
288
|
|
|
146
289
|
def rule_matches?(rule, identifier)
|
|
147
290
|
rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
|
|
148
291
|
end
|
|
149
292
|
|
|
150
|
-
# A matched :skip rule allows without evaluating: no counter exists, so
|
|
151
|
-
# info is nil (to_response_headers is {} for skip results).
|
|
152
|
-
def skip_result(rule)
|
|
153
|
-
Result.new(matched: true, action: :allow, rule: rule)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
293
|
def missing_count_distinct_value?(rule, identifier)
|
|
157
294
|
value = identifier[rule.count_distinct]
|
|
158
295
|
value.nil? || value.to_s.empty?
|
|
159
296
|
end
|
|
160
297
|
|
|
161
298
|
def evaluate_rule(rule, identifier, cost, rule_context)
|
|
299
|
+
return evaluate_ban_rule(rule, identifier, cost, rule_context) if rule.ban_for
|
|
300
|
+
|
|
162
301
|
redis_key = build_redis_key(rule, identifier)
|
|
163
302
|
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
164
303
|
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
@@ -172,21 +311,78 @@ module Labkit
|
|
|
172
311
|
incr_with_ttl(redis_key, resolved_period, cost)
|
|
173
312
|
end
|
|
174
313
|
|
|
175
|
-
|
|
314
|
+
build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
|
|
176
315
|
end
|
|
177
316
|
|
|
178
317
|
def peek_rule(rule, identifier, rule_context)
|
|
318
|
+
return peek_ban_rule(rule, identifier, rule_context) if rule.ban_for
|
|
319
|
+
|
|
179
320
|
redis_key = build_redis_key(rule, identifier)
|
|
180
321
|
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
181
322
|
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
182
323
|
|
|
183
324
|
count, ttl = rule.count_distinct ? scard_with_ttl(redis_key) : read_with_ttl(redis_key)
|
|
184
|
-
|
|
325
|
+
build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def evaluate_ban_rule(rule, identifier, cost, rule_context)
|
|
329
|
+
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
330
|
+
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
331
|
+
resolved_ban_for = resolve_ban_for(rule, rule_context)
|
|
332
|
+
|
|
333
|
+
count, ttl, ban_ttl = ban_incr_with_ttl(
|
|
334
|
+
build_redis_key(rule, identifier),
|
|
335
|
+
build_redis_key(rule, identifier, BAN_KEY_SUFFIX),
|
|
336
|
+
resolved_period, cost, resolved_limit, resolved_ban_for
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# A callable ban_for is only checked at construction, so it can still hand
|
|
343
|
+
# back something Redis would reject on the SET that writes the ban. Failing
|
|
344
|
+
# here names ban_for rather than surfacing a line number from the script.
|
|
345
|
+
def resolve_ban_for(rule, rule_context)
|
|
346
|
+
raw = resolve_value(rule.ban_for, rule_context)
|
|
347
|
+
seconds = Integer(raw, exception: false)
|
|
348
|
+
raise ArgumentError, "ban_for resolved to #{raw.inspect}, need at least 1 second" if seconds.nil? || seconds < 1
|
|
349
|
+
|
|
350
|
+
seconds
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def peek_ban_rule(rule, identifier, rule_context)
|
|
354
|
+
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
355
|
+
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
356
|
+
|
|
357
|
+
count, ttl, ban_ttl = read_ban_with_ttl(
|
|
358
|
+
build_redis_key(rule, identifier),
|
|
359
|
+
build_redis_key(rule, identifier, BAN_KEY_SUFFIX)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# A ban blocks whether it was written by this call or an earlier one, so
|
|
366
|
+
# +exceeded+ tracks the ban rather than the count: once the counter window
|
|
367
|
+
# has expired the count can sit below the limit while the ban still holds.
|
|
368
|
+
# reset_at reports when the caller may retry, which is the ban expiry.
|
|
369
|
+
def build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
|
|
370
|
+
banned = ban_ttl >= 0
|
|
371
|
+
window_remaining = ttl >= 0 ? ttl : resolved_period
|
|
372
|
+
|
|
373
|
+
info = Result::Info.new(
|
|
374
|
+
resolved_limit: resolved_limit, resolved_period: resolved_period,
|
|
375
|
+
count: count,
|
|
376
|
+
# A ban outlives its counter, so count reads 0 once the window has
|
|
377
|
+
# gone. Nothing is remaining while the ban still blocks.
|
|
378
|
+
remaining: banned ? 0 : [resolved_limit - count, 0].max,
|
|
379
|
+
reset_at: Time.now.utc + (banned ? ban_ttl : window_remaining)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
Result::Evaluation.new(rule: rule, exceeded: banned, info: info)
|
|
185
383
|
end
|
|
186
384
|
|
|
187
|
-
def
|
|
188
|
-
exceeded = count > resolved_limit
|
|
189
|
-
action = exceeded ? rule.action : :allow
|
|
385
|
+
def build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
|
|
190
386
|
info = Result::Info.new(
|
|
191
387
|
resolved_limit: resolved_limit, resolved_period: resolved_period,
|
|
192
388
|
count: count,
|
|
@@ -194,15 +390,22 @@ module Labkit
|
|
|
194
390
|
reset_at: Time.now.utc + (ttl >= 0 ? ttl : resolved_period)
|
|
195
391
|
)
|
|
196
392
|
|
|
197
|
-
Result.new(
|
|
393
|
+
Result::Evaluation.new(rule: rule, exceeded: count > resolved_limit, info: info)
|
|
198
394
|
end
|
|
199
395
|
|
|
200
|
-
|
|
201
|
-
|
|
396
|
+
# The limiter, rule and characteristics sit inside a Redis hash tag, so a
|
|
397
|
+
# counter and its ban always hash to the same cluster slot. BAN_SCRIPT
|
|
398
|
+
# touches both in one call, and Redis Cluster rejects a script whose keys
|
|
399
|
+
# span slots. +suffix+ is appended outside the tag, so it cannot change
|
|
400
|
+
# the slot and cannot collide with a characteristic value.
|
|
401
|
+
def build_redis_key(rule, identifier, suffix = nil)
|
|
402
|
+
key = "#{REDIS_KEY_PREFIX}:{#{@name}:#{rule.name}"
|
|
202
403
|
rule.characteristics.each do |char|
|
|
203
404
|
value = resolve_char_value(char, identifier)
|
|
204
|
-
key
|
|
405
|
+
key << ":#{char}:#{encode_char_value(value)}"
|
|
205
406
|
end
|
|
407
|
+
key << "}"
|
|
408
|
+
key << suffix if suffix
|
|
206
409
|
key
|
|
207
410
|
end
|
|
208
411
|
|
|
@@ -247,7 +450,7 @@ module Labkit
|
|
|
247
450
|
end
|
|
248
451
|
|
|
249
452
|
# Atomically increments the counter by `cost`, sets the TTL on first
|
|
250
|
-
# write, and
|
|
453
|
+
# write, and returns the window's remaining TTL, all in one Redis
|
|
251
454
|
# operation via Lua. See INCR_SCRIPT for the script body.
|
|
252
455
|
#
|
|
253
456
|
# count is parsed as Float because INCRBYFLOAT returns a string-encoded
|
|
@@ -260,9 +463,33 @@ module Labkit
|
|
|
260
463
|
end
|
|
261
464
|
end
|
|
262
465
|
|
|
466
|
+
# Atomic ban check + increment + conditional ban write. See BAN_SCRIPT.
|
|
467
|
+
# The returned ban TTL is negative when no ban is in force.
|
|
468
|
+
def ban_incr_with_ttl(counter_key, ban_key, period, cost, limit, ban_for)
|
|
469
|
+
@redis.with do |conn|
|
|
470
|
+
raw_count, ttl, ban_ttl = BAN_SCRIPT.eval(
|
|
471
|
+
conn, keys: [counter_key, ban_key], argv: [period, cost, limit, ban_for]
|
|
472
|
+
)
|
|
473
|
+
[Float(raw_count), Integer(ttl), Integer(ban_ttl)]
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Pipelined read of both keys for #peek. No EXPIRE and no SET: peeking a
|
|
478
|
+
# banned identifier must not extend either the window or the ban.
|
|
479
|
+
def read_ban_with_ttl(counter_key, ban_key)
|
|
480
|
+
@redis.with do |conn|
|
|
481
|
+
raw_count, ttl, ban_ttl = conn.pipelined do |pipe|
|
|
482
|
+
pipe.get(counter_key)
|
|
483
|
+
pipe.ttl(counter_key)
|
|
484
|
+
pipe.ttl(ban_key)
|
|
485
|
+
end
|
|
486
|
+
[raw_count.nil? ? 0.0 : Float(raw_count), Integer(ttl), Integer(ban_ttl)]
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
263
490
|
# Pipelined GET + TTL. No EXPIRE: peek must not extend the window.
|
|
264
491
|
# A missing key (GET => nil, TTL => -2) is reported as count=0; the
|
|
265
|
-
#
|
|
492
|
+
# build_evaluation fallback then derives reset_at from the rule period
|
|
266
493
|
# since there is no Redis-side window to read.
|
|
267
494
|
#
|
|
268
495
|
# Float parsing accepts both INCR-stored ("5") and INCRBYFLOAT-stored
|
|
@@ -303,14 +530,22 @@ module Labkit
|
|
|
303
530
|
end
|
|
304
531
|
end
|
|
305
532
|
|
|
306
|
-
|
|
533
|
+
# rule is nil when the error was raised with no rule in flight - before the
|
|
534
|
+
# loop reached one, or after it finished - so the field is logged as null
|
|
535
|
+
# rather than omitted, the same way identifier is. A named rule is the rule
|
|
536
|
+
# whose match or evaluation raised.
|
|
537
|
+
# Never raises: a logging failure must not break fail-open.
|
|
538
|
+
def log_error(error, identifier, rule = nil)
|
|
307
539
|
@logger.warn(
|
|
308
540
|
name: @name,
|
|
541
|
+
rule: rule&.name,
|
|
309
542
|
Labkit::Fields::ERROR_TYPE => "rate_limit_error",
|
|
310
543
|
Labkit::Fields::CLASS_NAME => error.class.to_s,
|
|
311
544
|
Labkit::Fields::ERROR_MESSAGE => error.message,
|
|
312
545
|
identifier: identifier&.to_h
|
|
313
546
|
)
|
|
547
|
+
rescue StandardError
|
|
548
|
+
nil
|
|
314
549
|
end
|
|
315
550
|
|
|
316
551
|
def log_missing_count_distinct(rule, identifier)
|
|
@@ -323,43 +558,67 @@ module Labkit
|
|
|
323
558
|
)
|
|
324
559
|
end
|
|
325
560
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
{ rate_limiter: @name, rule: result.rule.name },
|
|
334
|
-
result.info.resolved_limit
|
|
561
|
+
def report_evaluation_metrics(evaluation)
|
|
562
|
+
rule_labels = { rate_limiter: @name, rule: evaluation.rule.name }
|
|
563
|
+
|
|
564
|
+
Metrics.safe_increment(
|
|
565
|
+
:rule_evaluations_total,
|
|
566
|
+
rule_labels.merge(action: evaluation.rule.action.to_s, result: evaluation_result(evaluation)),
|
|
567
|
+
logger: @logger
|
|
335
568
|
)
|
|
336
|
-
Metrics.
|
|
337
|
-
|
|
338
|
-
|
|
569
|
+
# Deprecated dual emission - remove together with Metrics.calls_total.
|
|
570
|
+
Metrics.safe_increment(
|
|
571
|
+
:calls_total,
|
|
572
|
+
rule_labels.merge(action: (evaluation.exceeded? ? evaluation.rule.action : :allow).to_s),
|
|
573
|
+
logger: @logger
|
|
339
574
|
)
|
|
575
|
+
Metrics.safe_set(:limit_gauge, rule_labels, evaluation.info.resolved_limit, logger: @logger)
|
|
576
|
+
Metrics.safe_set(:period_gauge, rule_labels, evaluation.info.resolved_period, logger: @logger)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# An exceeded :log rule reports "log" rather than the "allow" the caller
|
|
580
|
+
# sees, so shadow rules over their limit stay visible.
|
|
581
|
+
# A ban is not a distinct action, so without this a blocking ban would be
|
|
582
|
+
# indistinguishable from an ordinary block. On a ban_for rule exceeded?
|
|
583
|
+
# means the ban is in force, whether this call wrote it or an earlier one
|
|
584
|
+
# did, which is exactly what is worth counting separately.
|
|
585
|
+
def evaluation_result(evaluation)
|
|
586
|
+
return "allow" unless evaluation.exceeded?
|
|
587
|
+
return "banned" if evaluation.rule.ban_for
|
|
588
|
+
|
|
589
|
+
evaluation.rule.action == :limit ? "block" : "log"
|
|
340
590
|
end
|
|
341
591
|
|
|
342
|
-
# calls_total carries action="skip" (the rule action, not the :allow the
|
|
343
|
-
# caller sees) so bypass traffic stays distinguishable from counted
|
|
344
|
-
# allows. No limit/period gauges: a skip rule has no limit to report.
|
|
345
592
|
def report_skipped_metrics(rule)
|
|
346
|
-
Metrics.
|
|
347
|
-
|
|
348
|
-
rule: rule.name,
|
|
349
|
-
|
|
593
|
+
Metrics.safe_increment(
|
|
594
|
+
:rule_evaluations_total,
|
|
595
|
+
{ rate_limiter: @name, rule: rule.name, action: "skip", result: "skip" },
|
|
596
|
+
logger: @logger
|
|
350
597
|
)
|
|
598
|
+
# Deprecated dual emission - remove together with Metrics.calls_total.
|
|
599
|
+
Metrics.safe_increment(:calls_total, { rate_limiter: @name, rule: rule.name, action: "skip" }, logger: @logger)
|
|
351
600
|
end
|
|
352
601
|
|
|
602
|
+
# Deprecated dual emission - remove together with Metrics.calls_total.
|
|
353
603
|
def report_unmatched_metrics
|
|
354
|
-
Metrics.calls_total
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
604
|
+
Metrics.safe_increment(:calls_total, { rate_limiter: @name, rule: "unmatched", action: "allow" }, logger: @logger)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def report_check_metrics(result)
|
|
608
|
+
Metrics.safe_increment(
|
|
609
|
+
:checks_total,
|
|
610
|
+
{
|
|
611
|
+
rate_limiter: @name,
|
|
612
|
+
action: result.action.to_s,
|
|
613
|
+
matched: result.matched?.to_s,
|
|
614
|
+
error: (result.error? || result.degraded?).to_s
|
|
615
|
+
},
|
|
616
|
+
logger: @logger
|
|
358
617
|
)
|
|
359
618
|
end
|
|
360
619
|
|
|
361
620
|
def report_error_metrics
|
|
362
|
-
Metrics.errors_total
|
|
621
|
+
Metrics.safe_increment(:errors_total, { rate_limiter: @name }, logger: @logger)
|
|
363
622
|
end
|
|
364
623
|
end
|
|
365
624
|
end
|
|
@@ -6,6 +6,9 @@ module Labkit
|
|
|
6
6
|
# describe the caller (e.g. user, ip, endpoint).
|
|
7
7
|
# Endpoint values are normalised at construction time (query string stripped).
|
|
8
8
|
class Identifier
|
|
9
|
+
InvalidKeyError = Class.new(ArgumentError)
|
|
10
|
+
DuplicateNormalizedKeyError = Class.new(InvalidKeyError)
|
|
11
|
+
|
|
9
12
|
# Normalize an endpoint value: strip query string.
|
|
10
13
|
def self.normalize_endpoint(value)
|
|
11
14
|
return value unless value.is_a?(String)
|
|
@@ -16,7 +19,29 @@ module Labkit
|
|
|
16
19
|
attr_reader :attributes
|
|
17
20
|
|
|
18
21
|
def initialize(attributes = {})
|
|
19
|
-
normalised =
|
|
22
|
+
normalised = {}
|
|
23
|
+
original_keys = {}
|
|
24
|
+
|
|
25
|
+
attributes.each do |key, value|
|
|
26
|
+
unless key.respond_to?(:to_sym)
|
|
27
|
+
# Reject keys such as nil, 42, and [] instead of leaking NoMethodError;
|
|
28
|
+
# identifiers must have a canonical symbol key for matching and serialization.
|
|
29
|
+
raise InvalidKeyError, "Identifier key #{key.inspect} must respond to #to_sym"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
normalised_key = key.to_sym
|
|
33
|
+
if normalised.key?(normalised_key)
|
|
34
|
+
# Reject { user: 1, "user" => 2 } (and its reverse order): silently
|
|
35
|
+
# choosing a value would make the rate-limit bucket insertion-order dependent.
|
|
36
|
+
raise DuplicateNormalizedKeyError,
|
|
37
|
+
"Identifier keys normalize to the same key #{normalised_key.inspect}: " \
|
|
38
|
+
"#{original_keys[normalised_key].inspect} and #{key.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
original_keys[normalised_key] = key
|
|
42
|
+
normalised[normalised_key] = value
|
|
43
|
+
end
|
|
44
|
+
|
|
20
45
|
normalised[:endpoint] = self.class.normalize_endpoint(normalised[:endpoint]) if normalised.key?(:endpoint)
|
|
21
46
|
@attributes = normalised.freeze
|
|
22
47
|
end
|