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.
@@ -15,7 +15,7 @@ module Labkit
15
15
  # rules: [Labkit::RateLimit::Rule.new(name: "api_user", limit: 100, period: 60, characteristics: [:user])]
16
16
  # )
17
17
  # result = limiter.check({ user: 42, ip: "1.2.3.4" })
18
- # render_429 if result.exceeded? && result.action == :block
18
+ # render_429 if result.action == :block
19
19
  class Limiter
20
20
  NAME_PATTERN = /\A[a-z0-9_]+\z/
21
21
 
@@ -45,7 +45,7 @@ module Labkit
45
45
  # the rule definition and the call site colocated.
46
46
  # @return [Result]
47
47
  def check(identifier, cost: 1, rule_context: nil)
48
- id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
48
+ id = to_identifier(identifier)
49
49
  @evaluator.check(id, cost: cost, rule_context: rule_context)
50
50
  end
51
51
 
@@ -65,12 +65,33 @@ module Labkit
65
65
  # @param rule_context [Hash, nil] see {#check}
66
66
  # @return [Result]
67
67
  def peek(identifier, rule_context: nil)
68
- id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
68
+ id = to_identifier(identifier)
69
69
  @evaluator.peek(id, rule_context: rule_context)
70
70
  end
71
71
 
72
+ # Discards this limiter's state for one identifier: every rule's counter,
73
+ # and any ban written by a rule carrying ban_for.
74
+ #
75
+ # For call sites where a later success should wipe earlier failures, such
76
+ # as an authentication ban cleared by a valid login. Counters otherwise
77
+ # only expire with their window; this is the only way to end one early.
78
+ #
79
+ # Scoped to this limiter and identifier, not to a single rule: a caller
80
+ # clearing after a success knows who succeeded, not which rules matched.
81
+ #
82
+ # @param identifier [Identifier, Hash] caller attributes
83
+ # @return [Integer] number of Redis keys removed; on a Redis error, those
84
+ # removed before it failed
85
+ def clear(identifier)
86
+ @evaluator.clear(to_identifier(identifier))
87
+ end
88
+
72
89
  private
73
90
 
91
+ def to_identifier(identifier)
92
+ identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
93
+ end
94
+
74
95
  def validate_name!(name)
75
96
  raise ArgumentError, "name must be a non-empty String" unless name.is_a?(String) && !name.empty?
76
97
  return name if NAME_PATTERN.match?(name)
@@ -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,29 +30,38 @@ 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
 
37
+ # Wall-clock budget for a single #match? call. Without it a match is
38
+ # bounded only by whatever global the host sets (40s in GitLab Rails,
39
+ # unbounded elsewhere), and a timeout reaches Evaluator's fail-open
40
+ # rescue, so the request goes unlimited.
41
+ #
42
+ # 50ms is a safety net against catastrophic backtracking, not a
43
+ # performance bound: real matches are far cheaper (median 0.087ms, worst
44
+ # 1.484ms across 6935 paths that timed out in production at 5ms). Those
45
+ # timeouts were wall-clock stalls, since Ruby counts real time and GC
46
+ # pauses on GitLab's git fleet have a p50 of ~72ms. Under 4x CPU
47
+ # oversubscription 50ms still fires on 0.16% of those matches, 5ms 1.33%.
48
+ MATCH_TIMEOUT_SECONDS = 0.05
49
+
27
50
  def self.build(input)
28
51
  case input
29
52
  when Regexp
30
- new(type: :re, value: input)
53
+ new(type: :re, value: with_match_timeout(input))
31
54
  when Hash
32
55
  from_hash(input)
33
56
  when Array
34
- raise ArgumentError,
35
- "rate-limit match value must be a single-key Hash like {re: \"...\"} or {eq: ...}, got #{truncate_for_error(input)}"
57
+ raise ArgumentError, invalid_shape_error(input)
36
58
  else
37
59
  new(type: :eq, value: input)
38
60
  end
39
61
  end
40
62
 
41
63
  def self.from_hash(input)
42
- if input.size != 1
43
- raise ArgumentError,
44
- "rate-limit match value must be a single-key Hash like {re: \"...\"} or {eq: ...}, got #{truncate_for_error(input)}"
45
- end
64
+ raise ArgumentError, invalid_shape_error(input) if input.size != 1
46
65
 
47
66
  type, source = input.first
48
67
  type_sym = type.to_sym
@@ -60,6 +79,13 @@ module Labkit
60
79
  case type_sym
61
80
  when :eq
62
81
  new(type: :eq, value: source)
82
+ when :oneOf
83
+ unless source.is_a?(Array)
84
+ raise ArgumentError,
85
+ "rate-limit match value {oneOf: ...} must be an Array, got #{truncate_for_error(source)}"
86
+ end
87
+
88
+ new(type: :oneOf, value: Set.new(source).freeze)
63
89
  when :re
64
90
  if source.to_s.length > MAX_REGEX_SOURCE_LENGTH
65
91
  raise ArgumentError,
@@ -67,7 +93,7 @@ module Labkit
67
93
  end
68
94
 
69
95
  begin
70
- new(type: :re, value: Regexp.new(source))
96
+ new(type: :re, value: with_match_timeout(source))
71
97
  rescue RegexpError, TypeError => e
72
98
  raise ArgumentError,
73
99
  "rate-limit match value {re: #{truncate_for_error(source)}} failed to compile: #{e.message}"
@@ -76,6 +102,38 @@ module Labkit
76
102
  end
77
103
  private_class_method :compile
78
104
 
105
+ # Compiles +source+ (a String pattern or an existing Regexp) into a
106
+ # Regexp bounded by MATCH_TIMEOUT_SECONDS.
107
+ #
108
+ # The timeout is set per-Regexp rather than via the global
109
+ # +Regexp.timeout=+: labkit is a library, and a global would silently
110
+ # change regex behaviour throughout the host application, including
111
+ # code unrelated to rate limiting.
112
+ #
113
+ # A Regexp that already carries its own timeout is returned untouched -
114
+ # an explicit choice by the rule author wins over our default.
115
+ #
116
+ # Recompiling an existing Regexp preserves its source and options
117
+ # (including the fixed-encoding flags) and therefore +#==+; only object
118
+ # identity changes.
119
+ def self.with_match_timeout(source)
120
+ return source if source.is_a?(Regexp) && source.timeout
121
+
122
+ if source.is_a?(Regexp)
123
+ Regexp.new(source.source, source.options, timeout: MATCH_TIMEOUT_SECONDS)
124
+ else
125
+ Regexp.new(source, timeout: MATCH_TIMEOUT_SECONDS)
126
+ end
127
+ end
128
+
129
+ private_class_method :with_match_timeout
130
+
131
+ def self.invalid_shape_error(input)
132
+ "rate-limit match value must be a single-key Hash like {re: \"...\"}, {eq: ...} or {oneOf: [...]}, " \
133
+ "got #{truncate_for_error(input)}"
134
+ end
135
+ private_class_method :invalid_shape_error
136
+
79
137
  def self.truncate_for_error(value)
80
138
  s = value.inspect
81
139
  s.length > ERROR_INSPECT_LIMIT ? "#{s[0, ERROR_INSPECT_LIMIT]}...(truncated)" : s
@@ -86,6 +144,8 @@ module Labkit
86
144
  case type
87
145
  when :eq
88
146
  value == identifier_value
147
+ when :oneOf
148
+ value.include?(identifier_value)
89
149
  when :re
90
150
  value.match?(identifier_value.to_s)
91
151
  else
@@ -1,13 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent-ruby"
4
+
3
5
  module Labkit
4
6
  module RateLimit
5
7
  module Metrics
8
+ # Process-wide once-latch for log_failure; specs reset via make_false.
9
+ FAILURE_LOGGED = Concurrent::AtomicBoolean.new(false)
10
+
6
11
  module_function
7
12
 
8
- # :log rules are non-terminating: a check that matched only :log rules
9
- # increments calls_total once per matched :log rule AND once with
10
- # rule="unmatched", action="allow", since no terminating decision was made.
13
+ # Metric emission must never affect the caller's outcome. Resolving the
14
+ # metric by name keeps a raising getter inside the rescue.
15
+ def safe_increment(counter, labels, logger: nil)
16
+ public_send(counter).increment(**labels) # rubocop:disable GitlabSecurity/PublicSend
17
+ rescue StandardError => e
18
+ log_failure(e, counter, labels, logger)
19
+ end
20
+
21
+ # Gauge counterpart of safe_increment.
22
+ def safe_set(gauge, labels, value, logger: nil)
23
+ public_send(gauge).set(labels, value) # rubocop:disable GitlabSecurity/PublicSend
24
+ rescue StandardError => e
25
+ log_failure(e, gauge, labels, logger)
26
+ end
27
+
28
+ # make_true returns true only for the flipping caller, so exactly one
29
+ # warn per process. Never raises - a raise here would defeat the
30
+ # callers' rescues.
31
+ def log_failure(error, metric, labels, logger)
32
+ return unless logger && FAILURE_LOGGED.make_true
33
+
34
+ logger.warn(
35
+ name: labels[:rate_limiter],
36
+ metric: metric.to_s,
37
+ Labkit::Fields::ERROR_TYPE => "rate_limit_metrics_error",
38
+ Labkit::Fields::CLASS_NAME => error.class.to_s,
39
+ Labkit::Fields::ERROR_MESSAGE => error.message
40
+ )
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ # Emitted exactly once per #check call, including calls that fail open;
46
+ # summing by rate_limiter gives the request rate through the limiter.
47
+ def checks_total
48
+ Labkit::Metrics::Client.counter(
49
+ :gitlab_labkit_rate_limiter_checks_total,
50
+ 'Total number of rate limit checks',
51
+ { rate_limiter: nil, action: nil, matched: nil, error: nil }
52
+ )
53
+ end
54
+
55
+ # Deprecated: superseded by checks_total and rule_evaluations_total.
56
+ # Emitted unchanged (once per matched rule, rule="unmatched" when none
57
+ # matched) until consumers migrate; will get removed in a follow-up release.
11
58
  def calls_total
12
59
  Labkit::Metrics::Client.counter(
13
60
  :gitlab_labkit_rate_limiter_calls_total,
@@ -16,6 +63,18 @@ module Labkit
16
63
  )
17
64
  end
18
65
 
66
+ # Emitted once per *evaluated* rule. Only matched rules are evaluated:
67
+ # Unmatched traffic is checks_total{matched="false"}
68
+ def rule_evaluations_total
69
+ Labkit::Metrics::Client.counter(
70
+ :gitlab_labkit_rate_limiter_rule_evaluations_total,
71
+ 'Total number of rate limit rule evaluations',
72
+ { rate_limiter: nil, rule: nil, action: nil, result: nil }
73
+ )
74
+ end
75
+
76
+ # Deprecated: superseded by checks_total{error="true"}. Still emitted
77
+ # because it remains the only error metric for #peek.
19
78
  def errors_total
20
79
  Labkit::Metrics::Client.counter(
21
80
  :gitlab_labkit_rate_limiter_errors_total,
@@ -3,34 +3,114 @@
3
3
  module Labkit
4
4
  module RateLimit
5
5
  # Result is the return value of Limiter#check.
6
- # matched? - true if a rule's match conditions were satisfied
7
- # exceeded? - true if the matched rule's counter exceeded its limit
8
- # action - the outcome: what the caller should do
9
- # :block = rule matched, exceeded, rule configured to block
10
- # :log = rule matched, exceeded, rule configured to log only
11
- # :allow = rule matched but count within limit, rule configured to allow,
12
- # rule configured to skip (bypass, nothing counted),
13
- # no rule matched, or error (fail-open)
14
- # The rule's configured action is available via rule.action.
15
- # rule - the matched Rule object (nil when matched? is false)
16
- # error? - true if Redis was unavailable; result fails open (exceeded? is false)
17
- # info - Result::Info with per-window counters; nil when matched? is false,
18
- # error?, or the matched rule is :skip (no counter exists)
19
- Result = Data.define(:matched, :exceeded, :action, :rule, :error, :info) do
20
- def initialize(matched:, action: nil, exceeded: false, rule: nil, error: false, info: nil)
21
- super
6
+ #
7
+ # It accumulates one Result::Evaluation per matched-and-counted rule; the
8
+ # reader methods report the single most-constraining evaluation (see
9
+ # #most_constraining). A matched :skip rule or a fail-open error replaces
10
+ # the evaluations as the source of the reported outcome.
11
+ #
12
+ # Result is a mutable accumulator, not a value object: two Results built
13
+ # from the same evaluations are not #==.
14
+ #
15
+ # matched? - true if at least one rule's match conditions were satisfied
16
+ # exceeded? - true if the reported evaluation's counter exceeded its limit
17
+ # action - the outcome: what the caller should do
18
+ # :block = a :limit rule matched and is over its limit
19
+ # :allow = everything else, including an exceeded :log rule
20
+ # (visible via exceeded?), a matched :skip rule, no rule
21
+ # matched, and error (fail-open).
22
+ # The rule's configured action is available via rule.action.
23
+ # rule - the reported Rule: the :skip rule when one matched, otherwise
24
+ # the most-constraining evaluated Rule (nil when matched? is
25
+ # false). Other rules may also have matched and been counted;
26
+ # see #evaluations.
27
+ # error? - true if Redis was unavailable; result fails open (exceeded? is false)
28
+ # degraded? - true if a matched count_distinct rule was skipped by the
29
+ # missing-key fail-open path; the check still completed
30
+ # info - Result::Info with per-window counters for the reported rule;
31
+ # nil when matched? is false, error?, or the matched rule is
32
+ # :skip (no counter exists)
33
+ # evaluations - every counted evaluation, in rule declaration order
34
+ class Result
35
+ attr_reader :evaluations
36
+
37
+ def self.error
38
+ new(error: true)
39
+ end
40
+
41
+ def initialize(error: false)
42
+ @evaluations = []
43
+ @skip_rule = nil
44
+ @error = error
45
+ @degraded = false
46
+ @most_constraining = nil
47
+ end
48
+
49
+ def add_evaluation(evaluation)
50
+ @most_constraining = nil
51
+ @evaluations << evaluation
52
+ self
53
+ end
54
+
55
+ # Records a matched :skip rule: the request is allowed and the skip rule
56
+ # is the one reported, regardless of any evaluations already collected.
57
+ def skip!(rule)
58
+ @skip_rule = rule
59
+ self
22
60
  end
23
61
 
24
62
  def matched?
25
- matched
63
+ skipped? || @evaluations.any?
64
+ end
65
+
66
+ def skipped?
67
+ !!@skip_rule
68
+ end
69
+
70
+ def block?
71
+ !skipped? && @evaluations.any?(&:block?)
26
72
  end
27
73
 
28
74
  def exceeded?
29
- exceeded
75
+ return false if skipped?
76
+
77
+ !most_constraining.nil? && most_constraining.exceeded?
30
78
  end
31
79
 
32
80
  def error?
33
- error
81
+ @error
82
+ end
83
+
84
+ # Unlike error?, the check keeps going: the flag marks the outcome as
85
+ # degraded without changing it.
86
+ def degraded!
87
+ @degraded = true
88
+ self
89
+ end
90
+
91
+ def degraded?
92
+ @degraded
93
+ end
94
+
95
+ def action
96
+ block? ? :block : :allow
97
+ end
98
+
99
+ def rule
100
+ @skip_rule || most_constraining&.rule
101
+ end
102
+
103
+ def info
104
+ return nil if skipped?
105
+
106
+ most_constraining&.info
107
+ end
108
+
109
+ # The evaluation with the strongest claim on the outcome; ties keep the
110
+ # earliest-declared rule (min returns the first of tied elements).
111
+ # Memoized; add_evaluation invalidates.
112
+ def most_constraining
113
+ @most_constraining ||= @evaluations.min
34
114
  end
35
115
 
36
116
  # Returns RFC-compliant rate limit response headers, or {} when no rule matched or an error occurred.
@@ -48,6 +128,40 @@ module Labkit
48
128
  end
49
129
  end
50
130
 
131
+ # The outcome of counting one matched rule.
132
+ # rule - the evaluated Rule
133
+ # exceeded - whether the post-increment count exceeded the resolved limit
134
+ # info - Result::Info with the per-window counters
135
+ #
136
+ # Evaluations order by constraint: a blocking evaluation (a :limit rule over
137
+ # its limit, or one whose ban is still in force) ranks strictly first - it
138
+ # decides the request no matter what any other rule reports - then exceeded
139
+ # ones, then fewest remaining.
140
+ # remaining floors at 0, so an exceeded :log rule ties with one sitting
141
+ # exactly on its limit; ranking exceeded ahead keeps a breach from being
142
+ # hidden by a rule that merely reached its limit.
143
+ Result::Evaluation = Data.define(:rule, :exceeded, :info) do
144
+ include Comparable
145
+
146
+ def exceeded?
147
+ exceeded
148
+ end
149
+
150
+ def block?
151
+ exceeded? && rule.action == :limit
152
+ end
153
+
154
+ def <=>(other)
155
+ constraint_rank <=> other.constraint_rank
156
+ end
157
+
158
+ protected
159
+
160
+ def constraint_rank
161
+ [block? ? 0 : 1, exceeded? ? 0 : 1, info.remaining]
162
+ end
163
+ end
164
+
51
165
  # Per-window counter data attached to a matched Result.
52
166
  # resolved_limit - the evaluated limit Integer for this rule
53
167
  # resolved_period - the evaluated period Integer (seconds) for this rule
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Labkit
4
4
  module RateLimit
5
- KNOWN_ACTIONS = %i[block log allow skip].freeze
5
+ KNOWN_ACTIONS = %i[limit log skip].freeze
6
6
  RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
7
7
  RULE_NAME_MAX_LENGTH = 64
8
8
 
@@ -12,13 +12,19 @@ module Labkit
12
12
  # the rule to apply; empty hash matches any identifier
13
13
  # limit - request threshold; may be a callable (resolved per check)
14
14
  # period - window in seconds; may be a callable (resolved per check)
15
- # action - :block (enforce), :log (count and log only, do not block,
16
- # evaluation continues to subsequent rules), :allow
17
- # (count but always permit; terminates evaluation on match
18
- # regardless of whether the limit was exceeded), or :skip
19
- # (bypass: permit and terminate evaluation on match without
20
- # counting; performs no Redis operation, so limit, period,
21
- # characteristics, and count_distinct are inert)
15
+ # action - :limit (enforce; the result blocks when the rule is over
16
+ # its limit, which also terminates evaluation), :log (count
17
+ # and log only, never blocks and never terminates),
18
+ # or :skip (bypass: permit and terminate evaluation on
19
+ # match without counting; performs no Redis operation, so
20
+ # limit, period, characteristics, and count_distinct are
21
+ # inert)
22
+ # ban_for - optional ban duration in seconds. On crossing the limit
23
+ # the rule writes a separate ban key that outlives the
24
+ # counter window, and stops counting while it holds.
25
+ # +action+ still decides who is blocked: :limit enforces
26
+ # the ban, :log records what it would have done. Rejected
27
+ # on :skip. May be a callable, resolved per check.
22
28
  # characteristics - identifier keys used to build the compound Redis counter key
23
29
  # count_distinct - optional Symbol naming an identifier key. When set, the rule
24
30
  # counts the number of distinct values seen for that key within
@@ -29,7 +35,27 @@ module Labkit
29
35
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
30
36
  # characters. It is used as the middle segment of every Redis counter key for
31
37
  # this rule, so changing a rule's name mid-window abandons its in-flight counters.
32
- Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct) do
38
+ Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct, :ban_for) do
39
+ # ban_for changes the accounting; action still decides who gets blocked.
40
+ # A :skip rule counts nothing, so it has no limit to cross and no ban to
41
+ # write.
42
+ def self.normalize_ban_for(value, action_sym, count_distinct_sym)
43
+ return nil if value.nil?
44
+
45
+ raise ArgumentError, "ban_for is not valid on a :skip rule" if action_sym == :skip
46
+
47
+ # The ban path counts with INCRBYFLOAT, so a SET-backed rule would
48
+ # silently count calls instead of distinct values.
49
+ raise ArgumentError, "ban_for cannot be combined with count_distinct #{count_distinct_sym.inspect}" if count_distinct_sym
50
+
51
+ return value if value.respond_to?(:call)
52
+ # Whole seconds: the ban is written with SET EX, and anything under a
53
+ # second truncates to 0, which Redis rejects.
54
+ return value if value.is_a?(Numeric) && value >= 1
55
+
56
+ raise ArgumentError, "ban_for must be a Numeric of at least 1 second or a callable, got #{value.inspect}"
57
+ end
58
+
33
59
  def self.normalize_count_distinct(value, characteristics_arr)
34
60
  sym =
35
61
  case value
@@ -45,7 +71,10 @@ module Labkit
45
71
  sym
46
72
  end
47
73
 
48
- def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block, count_distinct: nil)
74
+ def initialize(
75
+ name:, limit:, period:, characteristics:,
76
+ match: {}, action: :limit, count_distinct: nil, ban_for: nil
77
+ )
49
78
  raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
50
79
 
51
80
  name_str = name.to_s
@@ -60,6 +89,7 @@ module Labkit
60
89
  end
61
90
 
62
91
  characteristics_arr = Array(characteristics).map(&:to_sym).freeze
92
+ count_distinct_sym = self.class.normalize_count_distinct(count_distinct, characteristics_arr)
63
93
 
64
94
  super(
65
95
  name: name_str.freeze,
@@ -68,7 +98,8 @@ module Labkit
68
98
  period: period,
69
99
  action: action_sym,
70
100
  characteristics: characteristics_arr,
71
- count_distinct: self.class.normalize_count_distinct(count_distinct, characteristics_arr)
101
+ count_distinct: count_distinct_sym,
102
+ ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym)
72
103
  )
73
104
  end
74
105
  end
@@ -40,7 +40,8 @@ module Labkit
40
40
  #
41
41
  # @param name [String] call site name
42
42
  # @param identifier [Identifier, Hash] caller attributes
43
- # @param rules [Array<Rule>] ordered list of rules (first match wins)
43
+ # @param rules [Array<Rule>] ordered list of rules; every matching rule is
44
+ # counted, and the Result reports the most constraining one
44
45
  # @param redis [Object, nil] Redis client; falls back to config.redis
45
46
  # @param logger [Logger, nil] logger; falls back to config.logger
46
47
  # @param cost [Numeric] amount to add to the counter; see Limiter#check
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: 2.8.1
4
+ version: 4.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -716,7 +716,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
716
716
  - !ruby/object:Gem::Version
717
717
  version: '0'
718
718
  requirements: []
719
- rubygems_version: 4.0.10
719
+ rubygems_version: 4.0.16
720
720
  specification_version: 4
721
721
  summary: Instrumentation for GitLab
722
722
  test_files: []