gitlab-labkit 1.18.0 → 1.20.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: 154a169d5b47821eb411551dd452755270a671c334b9f041b7c20090b8d35074
4
- data.tar.gz: 5373cb3360f29eb1fe9facb99b866423a26785b61c9bc8390f6b4623dba6bff5
3
+ metadata.gz: f5f66c856712fe4736592757a1dab3a95aa920359ed0bbca72a638d0983df164
4
+ data.tar.gz: 89f0452f48f07968f29b3890fb7f71b1aac42b5f4b6e7cdcad556a0c3f548d6f
5
5
  SHA512:
6
- metadata.gz: d8393e9574bdeb229a8089dfdcbe90ed1d68bfd9c7a35a95f654800beb18dab5121b48f82c61d71d1d00a83ba6e60e5c595a020bb8e9237b5a636d019645afd0
7
- data.tar.gz: 7cf8a3cda6cca43339bf14e814cdd7ee28ebac6de0b4bf21b1c84400eb43bd4061fb0029852fb475293c16a0ae497abc5002556260dd1da680aeb39abc9fbc2c
6
+ metadata.gz: 1a9bf8af827028ae5e68857650cdf2af5e6c49c16360e123a1f653c0e38530ba89a4f1a5179ee50d540dc4f71a1fd766f93517d622d1a25d72d39be5e545b0b1
7
+ data.tar.gz: 58da58ed8eae9c02437fd0a3f41a529e36feeade438d9dcee63d4448165a95b4d1b3d100c1e27384aa5763a002fbcf9eccc33cb283258e8baafbfbc9366bc56b
data/.gitlab-ci.yml CHANGED
@@ -19,13 +19,13 @@ include:
19
19
  # It includes standard checks, gitlab-scanners, validations and release processes
20
20
  # common to all projects using this template library.
21
21
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/standard.md
22
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v3.23
22
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v3.24
23
23
 
24
24
  # Runs rspec tests and rubocop on the project
25
25
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/ruby.md
26
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v3.23
26
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v3.24
27
27
 
28
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v3.23
28
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v3.24
29
29
 
30
30
  ruby-versions:
31
31
  extends: rspec
@@ -25,7 +25,7 @@ repos:
25
25
  # Documentation available at
26
26
  # https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/docs/pre-commit.md
27
27
  - repo: https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks
28
- rev: v3.23 # renovate:managed
28
+ rev: v3.24 # renovate:managed
29
29
 
30
30
  hooks:
31
31
  - id: shellcheck # Run shellcheck for changed Shell files
@@ -29,23 +29,49 @@ module Labkit
29
29
  Result.new(matched: false, error: true, action: :allow)
30
30
  end
31
31
 
32
+ # Read-without-increment counterpart to {#check}. Same matching and Result
33
+ # shape; the underlying Redis counter is not mutated and the TTL is not
34
+ # extended. A missing Redis key is treated as count=0 (matched, not exceeded).
35
+ def peek(identifier)
36
+ peek_rules(identifier)
37
+ rescue StandardError => e
38
+ report_error_metrics
39
+ log_error(e, identifier)
40
+ Result.new(matched: false, error: true, action: :allow)
41
+ end
42
+
32
43
  private
33
44
 
45
+ # :log rules are non-terminating: they emit metrics and continue,
46
+ # so a shadow :log rule cannot disable a following :block rule.
34
47
  def check_rules(identifier)
35
48
  @rules.each do |rule|
36
49
  next unless rule_matches?(rule, identifier)
37
50
 
38
51
  result = evaluate_rule(rule, identifier)
39
52
  report_matched_metrics(result)
40
- return result
53
+ return result unless rule.action == :log
41
54
  end
42
55
 
43
56
  report_unmatched_metrics
44
57
  Result.new(matched: false, action: :allow)
45
58
  end
46
59
 
60
+ # Mirror of check_rules without metrics: peek skips :log rules (their state
61
+ # is unobservable through peek).
62
+ def peek_rules(identifier)
63
+ @rules.each do |rule|
64
+ next if rule.action == :log
65
+ next unless rule_matches?(rule, identifier)
66
+
67
+ return peek_rule(rule, identifier)
68
+ end
69
+
70
+ Result.new(matched: false, action: :allow)
71
+ end
72
+
47
73
  def rule_matches?(rule, identifier)
48
- rule.match.all? { |key, value| identifier[key] == value }
74
+ rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
49
75
  end
50
76
 
51
77
  def evaluate_rule(rule, identifier)
@@ -54,6 +80,19 @@ module Labkit
54
80
  resolved_period = Integer(resolve_value(rule.period))
55
81
 
56
82
  count, ttl = incr_with_ttl(redis_key, resolved_period)
83
+ build_result(rule, resolved_limit, resolved_period, count, ttl)
84
+ end
85
+
86
+ def peek_rule(rule, identifier)
87
+ redis_key = build_redis_key(rule, identifier)
88
+ resolved_limit = Integer(resolve_value(rule.limit))
89
+ resolved_period = Integer(resolve_value(rule.period))
90
+
91
+ count, ttl = read_with_ttl(redis_key)
92
+ build_result(rule, resolved_limit, resolved_period, count, ttl)
93
+ end
94
+
95
+ def build_result(rule, resolved_limit, resolved_period, count, ttl)
57
96
  exceeded = count > resolved_limit
58
97
  action = exceeded ? rule.action : :allow
59
98
  info = Result::Info.new(
@@ -108,6 +147,21 @@ module Labkit
108
147
  end
109
148
  end
110
149
 
150
+ # Pipelined GET + TTL. No EXPIRE: peek must not extend the window.
151
+ # A missing key (GET => nil, TTL => -2) is reported as count=0; the
152
+ # build_result fallback then derives reset_at from the rule period
153
+ # since there is no Redis-side window to read.
154
+ def read_with_ttl(redis_key)
155
+ @redis.with do |conn|
156
+ raw_count, ttl = conn.pipelined do |pipe|
157
+ pipe.get(redis_key)
158
+ pipe.ttl(redis_key)
159
+ end
160
+ count = raw_count.nil? ? 0 : Integer(raw_count)
161
+ [count, ttl]
162
+ end
163
+ end
164
+
111
165
  def log_error(error, identifier)
112
166
  @logger.warn(
113
167
  message: "rate_limit_error",
@@ -38,6 +38,25 @@ module Labkit
38
38
  @evaluator.check(id)
39
39
  end
40
40
 
41
+ # Read the current rate-limit state without incrementing the counter.
42
+ # Mirrors {#check} except the underlying counter is not mutated and the
43
+ # TTL is not extended. Useful for "have we already throttled this caller?"
44
+ # checks where the caller has another path that does the actual increment
45
+ # (typical pattern: peek to gate a side-effect, then call #check on the
46
+ # path that should count).
47
+ #
48
+ # When the underlying Redis key does not exist yet, the result reports
49
+ # count=0, exceeded=false, and remaining=resolved_limit; matched? is
50
+ # still true because the rule applied. On Redis error the result fails
51
+ # open identically to {#check}.
52
+ #
53
+ # @param identifier [Identifier, Hash] caller attributes for this request
54
+ # @return [Result]
55
+ def peek(identifier)
56
+ id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
57
+ @evaluator.peek(id)
58
+ end
59
+
41
60
  private
42
61
 
43
62
  def validate_name!(name)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ # Matcher is the internal representation of a single key/value predicate in
6
+ # a Rule#match hash. Rule.new normalizes every match value through
7
+ # Matcher.build; the Evaluator calls Matcher#match? per identifier value.
8
+ #
9
+ # Accepted input shapes (everything else raises ArgumentError):
10
+ # - any plain value (String, Symbol, Integer, ...) -> :eq matcher
11
+ # - a Regexp instance -> :re matcher (Ruby convenience)
12
+ # - { eq: <value> } -> :eq matcher (canonical, YAML-compatible)
13
+ # - { re: <String|Regexp> } -> :re matcher (canonical, YAML-compatible)
14
+ #
15
+ # Hash-key naming follows the metrics-catalog selector pattern. Glob,
16
+ # prefix, and other matcher kinds are intentionally out of scope here; see
17
+ # gitlab-com/gl-infra/production-engineering#28853 for that follow-up.
18
+ #
19
+ # An :re matcher coerces the identifier value via #to_s before applying
20
+ # the regex, so callers can match non-String identifier values such as
21
+ # Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
22
+ class Matcher < Data.define(:type, :value)
23
+ KNOWN_HASH_KEYS = %i[eq re].freeze
24
+ MAX_REGEX_SOURCE_LENGTH = 200
25
+ ERROR_INSPECT_LIMIT = 80
26
+
27
+ def self.build(input)
28
+ case input
29
+ when Regexp
30
+ new(type: :re, value: input)
31
+ when Hash
32
+ from_hash(input)
33
+ 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)}"
36
+ else
37
+ new(type: :eq, value: input)
38
+ end
39
+ end
40
+
41
+ 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
46
+
47
+ type, source = input.first
48
+ type_sym = type.to_sym
49
+
50
+ unless KNOWN_HASH_KEYS.include?(type_sym)
51
+ raise ArgumentError,
52
+ "rate-limit match value has unknown type key #{truncate_for_error(type)}; accepted: #{KNOWN_HASH_KEYS.inspect}"
53
+ end
54
+
55
+ compile(type_sym, source)
56
+ end
57
+ private_class_method :from_hash
58
+
59
+ def self.compile(type_sym, source)
60
+ case type_sym
61
+ when :eq
62
+ new(type: :eq, value: source)
63
+ when :re
64
+ if source.to_s.length > MAX_REGEX_SOURCE_LENGTH
65
+ raise ArgumentError,
66
+ "rate-limit match value {re: ...} source exceeds #{MAX_REGEX_SOURCE_LENGTH} characters"
67
+ end
68
+
69
+ begin
70
+ new(type: :re, value: Regexp.new(source))
71
+ rescue RegexpError, TypeError => e
72
+ raise ArgumentError,
73
+ "rate-limit match value {re: #{truncate_for_error(source)}} failed to compile: #{e.message}"
74
+ end
75
+ end
76
+ end
77
+ private_class_method :compile
78
+
79
+ def self.truncate_for_error(value)
80
+ s = value.inspect
81
+ s.length > ERROR_INSPECT_LIMIT ? "#{s[0, ERROR_INSPECT_LIMIT]}...(truncated)" : s
82
+ end
83
+ private_class_method :truncate_for_error
84
+
85
+ def match?(identifier_value)
86
+ case type
87
+ when :eq
88
+ value == identifier_value
89
+ when :re
90
+ value.match?(identifier_value.to_s)
91
+ else
92
+ raise ArgumentError, "unknown matcher type: #{type.inspect}"
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -5,6 +5,9 @@ module Labkit
5
5
  module Metrics
6
6
  module_function
7
7
 
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.
8
11
  def calls_total
9
12
  Labkit::Metrics::Client.counter(
10
13
  :gitlab_labkit_rate_limiter_calls_total,
@@ -8,9 +8,9 @@ module Labkit
8
8
  # action - the outcome: what the caller should do
9
9
  # :block = rule matched, exceeded, rule configured to block
10
10
  # :log = rule matched, exceeded, rule configured to log only
11
- # :allow = rule matched but count within limit, or
11
+ # :allow = rule matched but count within limit, rule configured to allow,
12
12
  # no rule matched, or error (fail-open)
13
- # The rule's configured action is available via rule.action
13
+ # The rule's configured action is available via rule.action.
14
14
  # rule - the matched Rule object (nil when matched? is false)
15
15
  # error? - true if Redis was unavailable; result fails open (exceeded? is false)
16
16
  # info - Result::Info with per-window counters; nil when matched? is false or error?
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Labkit
4
4
  module RateLimit
5
- KNOWN_ACTIONS = [:block, :log].freeze
5
+ KNOWN_ACTIONS = %i[block log allow].freeze
6
6
  RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
7
7
  RULE_NAME_MAX_LENGTH = 64
8
8
 
@@ -12,7 +12,9 @@ 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) or :log (count and log, but do not block)
15
+ # action - :block (enforce), :log (count and log only, do not block,
16
+ # evaluation continues to subsequent rules), or :allow
17
+ # (bypass: short-circuit evaluation with no Redis writes)
16
18
  # characteristics - identifier keys used to build the compound Redis counter key
17
19
  #
18
20
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
@@ -35,7 +37,7 @@ module Labkit
35
37
 
36
38
  super(
37
39
  name: name_str.freeze,
38
- match: match.transform_keys(&:to_sym).freeze,
40
+ match: match.transform_keys(&:to_sym).transform_values { |v| Matcher.build(v) }.freeze,
39
41
  limit: limit,
40
42
  period: period,
41
43
  action: action_sym,
@@ -19,6 +19,7 @@ module Labkit
19
19
  module RateLimit
20
20
  autoload :Configuration, "labkit/rate_limit/configuration"
21
21
  autoload :Identifier, "labkit/rate_limit/identifier"
22
+ autoload :Matcher, "labkit/rate_limit/matcher"
22
23
  autoload :Result, "labkit/rate_limit/result"
23
24
  autoload :Rule, "labkit/rate_limit/rule"
24
25
  autoload :Evaluator, "labkit/rate_limit/evaluator"
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: 1.18.0
4
+ version: 1.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -603,6 +603,7 @@ files:
603
603
  - lib/labkit/rate_limit/evaluator.rb
604
604
  - lib/labkit/rate_limit/identifier.rb
605
605
  - lib/labkit/rate_limit/limiter.rb
606
+ - lib/labkit/rate_limit/matcher.rb
606
607
  - lib/labkit/rate_limit/metrics.rb
607
608
  - lib/labkit/rate_limit/result.rb
608
609
  - lib/labkit/rate_limit/rule.rb