gitlab-labkit 1.17.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: 7177b8f9210f24958a14c92f17ad35cc8d4ad7732e04a9407569e865ef2bf364
4
- data.tar.gz: 688c6dd7b7c1a8c1062a1e17c534fa833f86053aff8945ab4d296c2223784711
3
+ metadata.gz: cb897d7171414d685d81aa2d55930e9633a46ac6aee0bdca4f36ed34b38e307e
4
+ data.tar.gz: bd171795616da76cf4262da0f70d6a4e3746bea4e5eab74b3fe554d3597a0c87
5
5
  SHA512:
6
- metadata.gz: 2b54fe16d5c1cfe12c704a4e84cdea8dece8155dcaa04132c9e52442bec03e6338f6a4bcb065aa87931aa2fedcbd2ecb5ecec390b00d110267ee54e95a5c694c
7
- data.tar.gz: 8abc6a48c3f1b138a84f8502e5c4300bec5f05f98b0c8defb4280d12b1d424f70cf64e0afe3cc344ce61ceae145e9aaf11f252d9611da1e5c6a1f25aa37fe867
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)
@@ -53,14 +74,30 @@ module Labkit
53
74
  resolved_limit = Integer(resolve_value(rule.limit))
54
75
  resolved_period = Integer(resolve_value(rule.period))
55
76
 
56
- count = incr_with_ttl(redis_key, resolved_period)
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
-
60
- Result.new(
61
- matched: true, exceeded: exceeded, action: action, rule: rule,
62
- resolved_limit: resolved_limit, resolved_period: resolved_period
93
+ info = Result::Info.new(
94
+ resolved_limit: resolved_limit, resolved_period: resolved_period,
95
+ count: count,
96
+ remaining: [resolved_limit - count, 0].max,
97
+ reset_at: Time.now.utc + (ttl >= 0 ? ttl : resolved_period)
63
98
  )
99
+
100
+ Result.new(matched: true, exceeded: exceeded, action: action, rule: rule, info: info)
64
101
  end
65
102
 
66
103
  def build_redis_key(rule, identifier)
@@ -91,12 +128,32 @@ module Labkit
91
128
  end
92
129
  end
93
130
 
131
+ # Pipelines INCR and TTL so both are fetched in a single round-trip.
132
+ # EXPIRE follows as a separate call only on first write (count == 1).
133
+ # On first write TTL will be -1 (expiry not yet set); callers fall back to period.
94
134
  def incr_with_ttl(redis_key, period)
95
135
  @redis.with do |conn|
96
- count = conn.incr(redis_key)
97
- # Set expiry only on first write to avoid resetting TTL on each call
136
+ count, ttl = conn.pipelined do |pipe|
137
+ pipe.incr(redis_key)
138
+ pipe.ttl(redis_key)
139
+ end
98
140
  conn.expire(redis_key, period) if count == 1
99
- count
141
+ [count, ttl]
142
+ end
143
+ end
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]
100
157
  end
101
158
  end
102
159
 
@@ -117,11 +174,11 @@ module Labkit
117
174
  )
118
175
  Metrics.limit_gauge.set(
119
176
  { rate_limiter: @name, rule: result.rule.name },
120
- result.resolved_limit
177
+ result.info.resolved_limit
121
178
  )
122
179
  Metrics.period_gauge.set(
123
180
  { rate_limiter: @name, rule: result.rule.name },
124
- result.resolved_period
181
+ result.info.resolved_period
125
182
  )
126
183
  end
127
184
 
@@ -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
@@ -3,22 +3,19 @@
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, or
12
- # no rule matched, or error (fail-open)
13
- # The rule's configured action is available via rule.action
14
- # rule - the matched Rule object (nil when matched? is false)
15
- # error? - true if Redis was unavailable; result fails open (exceeded? is false)
16
- # resolved_limit - the resolved limit value as Integer (nil when matched? is false or error)
17
- # resolved_period - the resolved period value as Integer (nil when matched? is false or error)
18
- Result = Data.define(:matched, :exceeded, :action, :rule, :error, :resolved_limit, :resolved_period) do
19
- def initialize(
20
- matched:, action:, exceeded: false, rule: nil, error: false,
21
- resolved_limit: nil, resolved_period: nil)
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, or
12
+ # no rule matched, or error (fail-open)
13
+ # The rule's configured action is available via rule.action
14
+ # rule - the matched Rule object (nil when matched? is false)
15
+ # error? - true if Redis was unavailable; result fails open (exceeded? is false)
16
+ # info - Result::Info with per-window counters; nil when matched? is false or error?
17
+ Result = Data.define(:matched, :exceeded, :action, :rule, :error, :info) do
18
+ def initialize(matched:, action: nil, exceeded: false, rule: nil, error: false, info: nil)
22
19
  super
23
20
  end
24
21
 
@@ -33,6 +30,27 @@ module Labkit
33
30
  def error?
34
31
  error
35
32
  end
33
+
34
+ # Returns RFC-compliant rate limit response headers, or {} when no rule matched or an error occurred.
35
+ # Keys: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (Unix timestamp).
36
+ # reset_at is advisory only - derived from a pipelined redis.ttl call, not fully atomic.
37
+ def to_response_headers
38
+ return {} unless matched? && !error? && info
39
+
40
+ {
41
+ "RateLimit-Limit" => info.resolved_limit.to_s,
42
+ "RateLimit-Remaining" => info.remaining.to_s,
43
+ "RateLimit-Reset" => info.reset_at.to_i.to_s
44
+ }
45
+ end
36
46
  end
47
+
48
+ # Per-window counter data attached to a matched Result.
49
+ # resolved_limit - the evaluated limit Integer for this rule
50
+ # resolved_period - the evaluated period Integer (seconds) for this rule
51
+ # count - the raw INCR value; useful for utilization-ratio metrics
52
+ # remaining - requests remaining before the limit is hit (floors at 0)
53
+ # reset_at - best-effort UTC Time when the counter window resets
54
+ Result::Info = Data.define(:resolved_limit, :resolved_period, :count, :remaining, :reset_at)
37
55
  end
38
56
  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.17.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