gitlab-labkit 4.2.0 → 4.4.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/lib/labkit/rate_limit/README.md +7 -0
- data/lib/labkit/rate_limit/evaluator.rb +31 -8
- data/lib/labkit/rate_limit/matcher.rb +28 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f25b6fb5022dbe4d1740df490b991da48f8d2d9432926a0c23ec41dcc566b1c0
|
|
4
|
+
data.tar.gz: 8e1fb5578809e9a87726e95d181fec9426c67c64aa5862e1ab6afd08d4ac5d20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dad133ba83a3e8e9b60825936befc9af2586bc80685b227c0517553c32681bd89ac42c0c9c2d7d68b3dd5865e2f54672ca4882711f7b71d92f67442af98abbb7
|
|
7
|
+
data.tar.gz: d0c781167401e351f2c0989cbdbb2d761886cc5f27b723d96d99228f33bb1a4f04b52af3669c8c655b512378f37842c09d920628abb9284212bf17a1e57d9b9e
|
|
@@ -174,10 +174,17 @@ A `match` hash gates whether a rule applies. Each value is normalised through
|
|
|
174
174
|
| `Regexp` | `re` | `match: { endpoint: %r{\A/api/} }` |
|
|
175
175
|
| `{ eq: <value> }` | `eq` | `match: { method: { eq: "POST" } }` (YAML-friendly) |
|
|
176
176
|
| `{ re: <source> }`| `re` | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |
|
|
177
|
+
| `{ oneOf: <Array> }` | `oneOf` | `match: { user: { oneOf: ["7", "42"] } }` (YAML-friendly) |
|
|
177
178
|
|
|
178
179
|
`re` coerces the identifier value via `#to_s` before matching, so it can be
|
|
179
180
|
used against non-String values (e.g. matching a 503 status against `{ re: '^5' }`).
|
|
180
181
|
|
|
182
|
+
`oneOf` is set membership via `Set#include?` (`eql?`/`hash` equality, no
|
|
183
|
+
coercion): it agrees with `eq` for Strings, Symbols, booleans, nil, and
|
|
184
|
+
same-class numerics, but cross-type numerics differ (`1 == 1.0`, yet
|
|
185
|
+
`{ oneOf: [1] }` does not match `1.0`). A bare Array is rejected; membership must
|
|
186
|
+
be spelled `{ oneOf: [...] }` explicitly.
|
|
187
|
+
|
|
181
188
|
Glob and prefix matchers are intentionally out of scope.
|
|
182
189
|
|
|
183
190
|
### Evaluation flow
|
|
@@ -12,6 +12,12 @@ module Labkit
|
|
|
12
12
|
CHAR_VALUE_MAX_LENGTH = 200
|
|
13
13
|
MISSING_VALUE_SENTINEL = "_unknown_"
|
|
14
14
|
|
|
15
|
+
# Carries the rule in flight out of the rule loop and into the rescue in
|
|
16
|
+
# #check/#peek, where the error is seen but the rule is out of scope.
|
|
17
|
+
# Per call, not instance state: one Evaluator serves concurrent requests.
|
|
18
|
+
# Only read while an exception unwinds; nil means no rule was in flight.
|
|
19
|
+
RuleCursor = Struct.new(:rule)
|
|
20
|
+
|
|
15
21
|
# Atomic increment-with-TTL Lua script. The whole script runs as one
|
|
16
22
|
# operation from Redis's perspective, so there is no window between
|
|
17
23
|
# the increment and EXPIRE that can leak a key without TTL.
|
|
@@ -68,12 +74,13 @@ module Labkit
|
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
def check(identifier, cost: 1, rule_context: nil)
|
|
71
|
-
|
|
77
|
+
cursor = RuleCursor.new
|
|
78
|
+
check_rules(identifier, cost, rule_context, cursor)
|
|
72
79
|
rescue StandardError => e
|
|
73
80
|
# Intentionally broad: fail-open applies to any unexpected error (network,
|
|
74
81
|
# timeout, OOM) not only Redis protocol errors.
|
|
75
82
|
report_error_metrics
|
|
76
|
-
log_error(e, identifier)
|
|
83
|
+
log_error(e, identifier, cursor.rule)
|
|
77
84
|
Result.error
|
|
78
85
|
end
|
|
79
86
|
|
|
@@ -81,10 +88,11 @@ module Labkit
|
|
|
81
88
|
# shape; the underlying Redis counter is not mutated and the TTL is not
|
|
82
89
|
# extended. A missing Redis key is treated as count=0 (matched, not exceeded).
|
|
83
90
|
def peek(identifier, rule_context: nil)
|
|
84
|
-
|
|
91
|
+
cursor = RuleCursor.new
|
|
92
|
+
peek_rules(identifier, rule_context, cursor)
|
|
85
93
|
rescue StandardError => e
|
|
86
94
|
report_error_metrics
|
|
87
|
-
log_error(e, identifier)
|
|
95
|
+
log_error(e, identifier, cursor.rule)
|
|
88
96
|
Result.error
|
|
89
97
|
end
|
|
90
98
|
|
|
@@ -117,11 +125,14 @@ module Labkit
|
|
|
117
125
|
# counters were incremented. No blocking verdict is lost that way - a
|
|
118
126
|
# :limit rule over its limit returns before any later rule can raise -
|
|
119
127
|
# but a rule declared after the failing one loses its chance to block.
|
|
120
|
-
# The request is counted and allowed.
|
|
121
|
-
|
|
128
|
+
# The request is counted and allowed. +cursor+ is set before the match, so
|
|
129
|
+
# a raise from the match itself (e.g. Regexp::TimeoutError) is attributed.
|
|
130
|
+
def check_rules(identifier, cost, rule_context, cursor)
|
|
122
131
|
result = Result.new
|
|
123
132
|
|
|
124
133
|
@rules.each do |rule|
|
|
134
|
+
cursor.rule = rule
|
|
135
|
+
|
|
125
136
|
next unless rule_matches?(rule, identifier)
|
|
126
137
|
|
|
127
138
|
if rule.action == :skip
|
|
@@ -141,6 +152,9 @@ module Labkit
|
|
|
141
152
|
return result if result.block?
|
|
142
153
|
end
|
|
143
154
|
|
|
155
|
+
# The loop is done, so anything raised from here on belongs to no rule.
|
|
156
|
+
cursor.rule = nil
|
|
157
|
+
|
|
144
158
|
report_unmatched_metrics unless result.matched?
|
|
145
159
|
result
|
|
146
160
|
end
|
|
@@ -152,10 +166,12 @@ module Labkit
|
|
|
152
166
|
# peek does not need the count_distinct identifier key - it reads SCARD on
|
|
153
167
|
# the rule-keyed compound key, which contains the cardinality across all
|
|
154
168
|
# members. So missing-key fail-open does not apply here.
|
|
155
|
-
def peek_rules(identifier, rule_context)
|
|
169
|
+
def peek_rules(identifier, rule_context, cursor)
|
|
156
170
|
result = Result.new
|
|
157
171
|
|
|
158
172
|
@rules.each do |rule|
|
|
173
|
+
cursor.rule = rule
|
|
174
|
+
|
|
159
175
|
next unless rule_matches?(rule, identifier)
|
|
160
176
|
|
|
161
177
|
return result.skip!(rule) if rule.action == :skip
|
|
@@ -164,6 +180,8 @@ module Labkit
|
|
|
164
180
|
return result if result.block?
|
|
165
181
|
end
|
|
166
182
|
|
|
183
|
+
cursor.rule = nil
|
|
184
|
+
|
|
167
185
|
result
|
|
168
186
|
end
|
|
169
187
|
|
|
@@ -319,9 +337,14 @@ module Labkit
|
|
|
319
337
|
end
|
|
320
338
|
end
|
|
321
339
|
|
|
322
|
-
|
|
340
|
+
# rule is nil when the error was raised with no rule in flight - before the
|
|
341
|
+
# loop reached one, or after it finished - so the field is logged as null
|
|
342
|
+
# rather than omitted, the same way identifier is. A named rule is the rule
|
|
343
|
+
# whose match or evaluation raised.
|
|
344
|
+
def log_error(error, identifier, rule = nil)
|
|
323
345
|
@logger.warn(
|
|
324
346
|
name: @name,
|
|
347
|
+
rule: rule&.name,
|
|
325
348
|
Labkit::Fields::ERROR_TYPE => "rate_limit_error",
|
|
326
349
|
Labkit::Fields::CLASS_NAME => error.class.to_s,
|
|
327
350
|
Labkit::Fields::ERROR_MESSAGE => error.message,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module Labkit
|
|
4
6
|
module RateLimit
|
|
5
7
|
# Matcher is the internal representation of a single key/value predicate in
|
|
@@ -11,6 +13,14 @@ module Labkit
|
|
|
11
13
|
# - a Regexp instance -> :re matcher (Ruby convenience)
|
|
12
14
|
# - { eq: <value> } -> :eq matcher (canonical, YAML-compatible)
|
|
13
15
|
# - { re: <String|Regexp> } -> :re matcher (canonical, YAML-compatible)
|
|
16
|
+
# - { oneOf: <Array> } -> :oneOf matcher (set membership, YAML-compatible)
|
|
17
|
+
#
|
|
18
|
+
# A bare Array is rejected rather than treated as :oneOf, so a value that
|
|
19
|
+
# was meant as a single matcher hash but arrived as an Array fails loudly
|
|
20
|
+
# instead of silently becoming a membership test.
|
|
21
|
+
#
|
|
22
|
+
# :oneOf membership is Set#include? (eql?/hash equality), not :eq's ==, so
|
|
23
|
+
# cross-type numerics differ (1 == 1.0, but {oneOf: [1]} does not match 1.0).
|
|
14
24
|
#
|
|
15
25
|
# Hash-key naming follows the metrics-catalog selector pattern. Glob,
|
|
16
26
|
# prefix, and other matcher kinds are intentionally out of scope here; see
|
|
@@ -20,7 +30,7 @@ module Labkit
|
|
|
20
30
|
# the regex, so callers can match non-String identifier values such as
|
|
21
31
|
# Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
|
|
22
32
|
class Matcher < Data.define(:type, :value)
|
|
23
|
-
KNOWN_HASH_KEYS = %i[eq re].freeze
|
|
33
|
+
KNOWN_HASH_KEYS = %i[eq re oneOf].freeze
|
|
24
34
|
MAX_REGEX_SOURCE_LENGTH = 200
|
|
25
35
|
ERROR_INSPECT_LIMIT = 80
|
|
26
36
|
|
|
@@ -42,18 +52,14 @@ module Labkit
|
|
|
42
52
|
when Hash
|
|
43
53
|
from_hash(input)
|
|
44
54
|
when Array
|
|
45
|
-
raise ArgumentError,
|
|
46
|
-
"rate-limit match value must be a single-key Hash like {re: \"...\"} or {eq: ...}, got #{truncate_for_error(input)}"
|
|
55
|
+
raise ArgumentError, invalid_shape_error(input)
|
|
47
56
|
else
|
|
48
57
|
new(type: :eq, value: input)
|
|
49
58
|
end
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
def self.from_hash(input)
|
|
53
|
-
if input.size != 1
|
|
54
|
-
raise ArgumentError,
|
|
55
|
-
"rate-limit match value must be a single-key Hash like {re: \"...\"} or {eq: ...}, got #{truncate_for_error(input)}"
|
|
56
|
-
end
|
|
62
|
+
raise ArgumentError, invalid_shape_error(input) if input.size != 1
|
|
57
63
|
|
|
58
64
|
type, source = input.first
|
|
59
65
|
type_sym = type.to_sym
|
|
@@ -71,6 +77,13 @@ module Labkit
|
|
|
71
77
|
case type_sym
|
|
72
78
|
when :eq
|
|
73
79
|
new(type: :eq, value: source)
|
|
80
|
+
when :oneOf
|
|
81
|
+
unless source.is_a?(Array)
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"rate-limit match value {oneOf: ...} must be an Array, got #{truncate_for_error(source)}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
new(type: :oneOf, value: Set.new(source).freeze)
|
|
74
87
|
when :re
|
|
75
88
|
if source.to_s.length > MAX_REGEX_SOURCE_LENGTH
|
|
76
89
|
raise ArgumentError,
|
|
@@ -113,6 +126,12 @@ module Labkit
|
|
|
113
126
|
|
|
114
127
|
private_class_method :with_match_timeout
|
|
115
128
|
|
|
129
|
+
def self.invalid_shape_error(input)
|
|
130
|
+
"rate-limit match value must be a single-key Hash like {re: \"...\"}, {eq: ...} or {oneOf: [...]}, " \
|
|
131
|
+
"got #{truncate_for_error(input)}"
|
|
132
|
+
end
|
|
133
|
+
private_class_method :invalid_shape_error
|
|
134
|
+
|
|
116
135
|
def self.truncate_for_error(value)
|
|
117
136
|
s = value.inspect
|
|
118
137
|
s.length > ERROR_INSPECT_LIMIT ? "#{s[0, ERROR_INSPECT_LIMIT]}...(truncated)" : s
|
|
@@ -123,6 +142,8 @@ module Labkit
|
|
|
123
142
|
case type
|
|
124
143
|
when :eq
|
|
125
144
|
value == identifier_value
|
|
145
|
+
when :oneOf
|
|
146
|
+
value.include?(identifier_value)
|
|
126
147
|
when :re
|
|
127
148
|
value.match?(identifier_value.to_s)
|
|
128
149
|
else
|