gitlab-labkit 1.18.0 → 1.19.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: cb897d7171414d685d81aa2d55930e9633a46ac6aee0bdca4f36ed34b38e307e
4
+ data.tar.gz: bd171795616da76cf4262da0f70d6a4e3746bea4e5eab74b3fe554d3597a0c87
5
5
  SHA512:
6
- metadata.gz: d8393e9574bdeb229a8089dfdcbe90ed1d68bfd9c7a35a95f654800beb18dab5121b48f82c61d71d1d00a83ba6e60e5c595a020bb8e9237b5a636d019645afd0
7
- data.tar.gz: 7cf8a3cda6cca43339bf14e814cdd7ee28ebac6de0b4bf21b1c84400eb43bd4061fb0029852fb475293c16a0ae497abc5002556260dd1da680aeb39abc9fbc2c
6
+ metadata.gz: 4d4174c89606b5949876451b31bdb40a76db573284ed5976afe9806eb33ac592e90d425cb8c3c48412756be9a7db777cfdb00f14649601ff8d0bcc05807bc8c9
7
+ data.tar.gz: b46236e5b3f59f97cd371f250c397553c32656a51994b556ae43fa4751b2eeb95c5b5bdbc3276d5ad156c861df7f7ff4f39ec93bd3826c854dbfff975fc77602
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,6 +29,17 @@ 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
 
34
45
  def check_rules(identifier)
@@ -44,8 +55,18 @@ module Labkit
44
55
  Result.new(matched: false, action: :allow)
45
56
  end
46
57
 
58
+ def peek_rules(identifier)
59
+ @rules.each do |rule|
60
+ next unless rule_matches?(rule, identifier)
61
+
62
+ return peek_rule(rule, identifier)
63
+ end
64
+
65
+ Result.new(matched: false, action: :allow)
66
+ end
67
+
47
68
  def rule_matches?(rule, identifier)
48
- rule.match.all? { |key, value| identifier[key] == value }
69
+ rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
49
70
  end
50
71
 
51
72
  def evaluate_rule(rule, identifier)
@@ -54,6 +75,19 @@ module Labkit
54
75
  resolved_period = Integer(resolve_value(rule.period))
55
76
 
56
77
  count, ttl = incr_with_ttl(redis_key, resolved_period)
78
+ build_result(rule, resolved_limit, resolved_period, count, ttl)
79
+ end
80
+
81
+ def peek_rule(rule, identifier)
82
+ redis_key = build_redis_key(rule, identifier)
83
+ resolved_limit = Integer(resolve_value(rule.limit))
84
+ resolved_period = Integer(resolve_value(rule.period))
85
+
86
+ count, ttl = read_with_ttl(redis_key)
87
+ build_result(rule, resolved_limit, resolved_period, count, ttl)
88
+ end
89
+
90
+ def build_result(rule, resolved_limit, resolved_period, count, ttl)
57
91
  exceeded = count > resolved_limit
58
92
  action = exceeded ? rule.action : :allow
59
93
  info = Result::Info.new(
@@ -108,6 +142,21 @@ module Labkit
108
142
  end
109
143
  end
110
144
 
145
+ # Pipelined GET + TTL. No EXPIRE: peek must not extend the window.
146
+ # A missing key (GET => nil, TTL => -2) is reported as count=0; the
147
+ # build_result fallback then derives reset_at from the rule period
148
+ # since there is no Redis-side window to read.
149
+ def read_with_ttl(redis_key)
150
+ @redis.with do |conn|
151
+ raw_count, ttl = conn.pipelined do |pipe|
152
+ pipe.get(redis_key)
153
+ pipe.ttl(redis_key)
154
+ end
155
+ count = raw_count.nil? ? 0 : Integer(raw_count)
156
+ [count, ttl]
157
+ end
158
+ end
159
+
111
160
  def log_error(error, identifier)
112
161
  @logger.warn(
113
162
  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
@@ -35,7 +35,7 @@ module Labkit
35
35
 
36
36
  super(
37
37
  name: name_str.freeze,
38
- match: match.transform_keys(&:to_sym).freeze,
38
+ match: match.transform_keys(&:to_sym).transform_values { |v| Matcher.build(v) }.freeze,
39
39
  limit: limit,
40
40
  period: period,
41
41
  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.19.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