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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1092d43de21613288d5ed802539e0eeab87bee9e8975e223f78aa29116f6cb2d
4
- data.tar.gz: 7a4953fd47e09da3cc2f4657cbe563648fec3ea0f0ae63d47e0bf49610b3b39d
3
+ metadata.gz: f25b6fb5022dbe4d1740df490b991da48f8d2d9432926a0c23ec41dcc566b1c0
4
+ data.tar.gz: 8e1fb5578809e9a87726e95d181fec9426c67c64aa5862e1ab6afd08d4ac5d20
5
5
  SHA512:
6
- metadata.gz: 5f885a5454c551e8adc0fd4ad8ca99c81bbc438a61b4787e122fae02f966062d8006c9766cb98f73d2e4c4465a3d5c3a3dd9fe13e44eb76ca21816b1c60571a1
7
- data.tar.gz: b11dc29d11ea325ea787af6ad1827bb9963146427d0930c38089b4f1901d21e1faca20437f52cd4a4f8de7b7de85fc0de2932cdcc5f8279c8e9fbe1cd77f711b
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
- check_rules(identifier, cost, rule_context)
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
- peek_rules(identifier, rule_context)
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
- def check_rules(identifier, cost, rule_context)
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
- def log_error(error, identifier)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-labkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.2.0
4
+ version: 4.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate