gitlab-labkit 1.13.0 → 1.15.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: 8db8c324d6fe60ad1b16ab51dd87207d4a5d08e1d83ad99a3ae900392bd0739c
4
- data.tar.gz: 4487ae2ff2bacac400f753c92012cd7b118525f0bed143794ca4fd12c14cca53
3
+ metadata.gz: 5840ade4baa1fb379ae82748354a74571d6806e529c205010139846489f50579
4
+ data.tar.gz: 802528c013a130b2925b19760a29e8d7c33aa9c042ed3a024ac0dc9bb8fa6127
5
5
  SHA512:
6
- metadata.gz: 7292067a1a5d8e231776b65b381ba161a2123c6363df5ebd4269ae65b596871ebddb5bd610e5ab33da0939cdeab4639a51bcb2636046cfd8cb941ac6f6baa9bb
7
- data.tar.gz: 771b7fded81f969ee414ef737b74fa9c22d620762f0e56c669e89de9c4ebf6c022fdbf91513fe630a580acfc8408d20a0eb55e3f05e0c97c412ab6ab818a75bf
6
+ metadata.gz: 9e3b44e0271effb09c593dc582cd69221c3f83b255535d5fd456033f22f6f933fe9c15e8a204d8868fa20644838bdfbf17578d151eb9fe906a731b650bbb1902
7
+ data.tar.gz: 98483520ad13fbfe52edd39ce6c879ea86b6240cdbf16e8deb9254abfce4d1e825029560119f79d76254efac3d97228ce35243cba21b3f269a32ec6f55612a36
data/lib/gitlab-labkit.rb CHANGED
@@ -5,6 +5,12 @@
5
5
  # infrastructural concerns, partcularly related to
6
6
  # observability.
7
7
  module Labkit
8
+ class << self
9
+ def dev_or_test?
10
+ %w[development test].include?(ENV.fetch("RAILS_ENV", nil))
11
+ end
12
+ end
13
+
8
14
  autoload :System, "labkit/system"
9
15
 
10
16
  autoload :Context, "labkit/context"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ class Configuration
6
+ attr_accessor :redis, :logger
7
+ end
8
+ end
9
+ end
@@ -1,123 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openssl"
4
- require "labkit/logging/json_logger"
5
4
 
6
5
  module Labkit
7
6
  module RateLimit
8
- # Evaluator contains the core rule-matching + Redis counter logic.
7
+ # Evaluator holds the static parts of a rate limit check (name, rules, Redis)
8
+ # and exposes a per-request #check(identifier) method.
9
+ # @api private
9
10
  class Evaluator
10
- KNOWN_CHARACTERISTICS = [:user, :ip, :namespace, :plan, :endpoint].freeze
11
- KNOWN_ACTIONS = [:block, :log].freeze
12
11
  REDIS_KEY_PREFIX = "labkit:rl"
13
12
  CHAR_VALUE_MAX_LENGTH = 200
14
- UNKNOWN_SENTINEL = "unknown_characteristic"
15
- CALL_SITE_PATTERN = /\A[a-z0-9_]+\z/
16
-
17
- def initialize(call_site:, identifier:, rules:, redis:, logger: nil)
18
- @call_site = call_site
19
- @identifier = identifier
20
- @rules = rules
21
- @redis = redis
22
- @logger = logger || build_default_logger
13
+ MISSING_VALUE_SENTINEL = "_unknown_"
14
+
15
+ def initialize(name:, rules:, redis:, logger:)
16
+ @name = name
17
+ @rules = rules
18
+ @redis = redis
19
+ @logger = logger
23
20
  end
24
21
 
25
- def evaluate
26
- validate_call_site!
27
- evaluate_rules
28
- rescue ArgumentError
29
- raise
22
+ def check(identifier)
23
+ check_rules(identifier)
30
24
  rescue StandardError => e
31
25
  # Intentionally broad: fail-open applies to any unexpected error (network,
32
- # timeout, OOM, etc.), not only Redis protocol errors.
33
- log_evaluate_error(e)
34
- :allow
26
+ # timeout, OOM) not only Redis protocol errors.
27
+ log_error(e, identifier)
28
+ Result.new(matched: false, error: true)
35
29
  end
36
30
 
37
31
  private
38
32
 
39
- def evaluate_rules
40
- aggregate = :allow
41
-
42
- @rules.each_with_index do |rule, index|
43
- next unless rule_matches?(rule, @identifier)
33
+ def check_rules(identifier)
34
+ @rules.each do |rule|
35
+ next unless rule_matches?(rule, identifier)
44
36
 
45
- result = evaluate_rule(rule, index)
46
- aggregate = :block if result == :block
37
+ return evaluate_rule(rule, identifier)
47
38
  end
48
39
 
49
- aggregate
50
- end
51
-
52
- def validate_call_site!
53
- return if CALL_SITE_PATTERN.match?(@call_site)
54
-
55
- raise ArgumentError, "Invalid call_site: #{@call_site.inspect}. Must match /\\A[a-z0-9_]+\\z/" if dev_or_test?
56
-
57
- sanitized = @call_site.gsub(/[^a-z0-9_]/, "_")
58
- @logger.warn(
59
- message: "rate_limit_invalid_call_site",
60
- call_site: @call_site,
61
- sanitized: sanitized
62
- )
63
- @call_site = sanitized
40
+ Result.new(matched: false)
64
41
  end
65
42
 
66
43
  def rule_matches?(rule, identifier)
67
- rule.match.all? do |key, value|
68
- identifier[key] == value
69
- end
44
+ rule.match.all? { |key, value| identifier[key] == value }
70
45
  end
71
46
 
72
- def evaluate_rule(rule, index)
73
- exceeded = false
74
-
75
- rule.characteristics.each do |char|
76
- char_value = resolve_characteristic(char, @identifier)
77
-
78
- if char_value.nil?
79
- log_skipped_characteristic(rule, index, char)
80
- next
81
- end
47
+ def evaluate_rule(rule, identifier)
48
+ redis_key = build_redis_key(rule, identifier)
49
+ resolved_limit = Integer(resolve_value(rule.limit))
50
+ resolved_period = Integer(resolve_value(rule.period))
82
51
 
83
- redis_key = build_redis_key(@call_site, index, char, char_value)
84
-
85
- count = incr_with_ttl(redis_key, rule.period)
86
- rule_exceeded = count > rule.limit
87
-
88
- exceeded = true if rule_exceeded
89
-
90
- log_rule(rule, index, count, redis_key, rule_exceeded)
91
- end
52
+ count = incr_with_ttl(redis_key, resolved_period)
53
+ exceeded = count > resolved_limit
92
54
 
93
- exceeded && rule.action == :block ? :block : :allow
55
+ Result.new(matched: true, exceeded: exceeded, action: rule.action, rule: rule)
94
56
  end
95
57
 
96
- def resolve_characteristic(char, identifier)
97
- unless KNOWN_CHARACTERISTICS.include?(char)
98
- raise ArgumentError, "Unknown characteristic: #{char.inspect}. Known: #{KNOWN_CHARACTERISTICS.inspect}" if dev_or_test?
99
-
100
- @logger.warn(
101
- message: "rate_limit_unknown_characteristic",
102
- characteristic: char
103
- )
104
- return UNKNOWN_SENTINEL
58
+ def build_redis_key(rule, identifier)
59
+ key = "#{REDIS_KEY_PREFIX}:#{@name}:#{rule.name}"
60
+ rule.characteristics.each do |char|
61
+ value = resolve_char_value(char, identifier)
62
+ key += ":#{char}:#{encode_char_value(value)}"
105
63
  end
64
+ key
65
+ end
106
66
 
67
+ def resolve_char_value(char, identifier)
107
68
  value = identifier[char]
108
-
109
- # Normalize endpoint: strip query string
110
- value = Identifier.normalize_endpoint(value) if char == :endpoint
111
-
112
- # Treat nil and empty-string the same: anonymous traffic must not collide on a shared bucket.
113
- return nil if value.nil? || value.to_s.empty?
69
+ return MISSING_VALUE_SENTINEL if value.nil? || value.to_s.empty?
114
70
 
115
71
  value.to_s
116
72
  end
117
73
 
118
- def build_redis_key(call_site, rule_index, char, char_value)
119
- safe_value = encode_char_value(char_value.to_s)
120
- "#{REDIS_KEY_PREFIX}:#{call_site}:#{rule_index}:#{char}:#{safe_value}"
74
+ def resolve_value(val)
75
+ val.respond_to?(:call) ? val.call : val
121
76
  end
122
77
 
123
78
  def encode_char_value(value)
@@ -135,57 +90,14 @@ module Labkit
135
90
  count
136
91
  end
137
92
 
138
- def log_rule(rule, index, count, redis_key, exceeded)
139
- @logger.info(
140
- message: "rate_limit_check",
141
- call_site: @call_site,
142
- rule_index: index,
143
- action: rule.action.to_s,
144
- limit: rule.limit,
145
- period: rule.period,
146
- count: count,
147
- matched: true,
148
- exceeded: exceeded,
149
- identifier: @identifier.to_h,
150
- redis_key: redis_key
151
- )
152
- end
153
-
154
- def log_skipped_characteristic(rule, index, char)
155
- @logger.info(
156
- message: "rate_limit_check",
157
- call_site: @call_site,
158
- rule_index: index,
159
- action: rule.action.to_s,
160
- limit: rule.limit,
161
- period: rule.period,
162
- characteristic: char,
163
- matched: true,
164
- skipped: true,
165
- identifier: @identifier.to_h
166
- )
167
- end
168
-
169
- def log_evaluate_error(error)
93
+ def log_error(error, identifier)
170
94
  @logger.warn(
171
- message: "rate_limit_redis_error",
172
- call_site: @call_site,
95
+ message: "rate_limit_error",
96
+ name: @name,
173
97
  error: error.class.to_s,
174
- result: "allow"
98
+ identifier: identifier&.to_h
175
99
  )
176
100
  end
177
-
178
- def dev_or_test?
179
- # Memoized: ENV access is not free under concurrency.
180
- return @dev_or_test unless @dev_or_test.nil?
181
-
182
- env = ENV.fetch("LABKIT_ENV", nil)
183
- @dev_or_test = env == "test" || env == "development"
184
- end
185
-
186
- def build_default_logger
187
- Labkit::Logging::JsonLogger.new($stdout)
188
- end
189
101
  end
190
102
  end
191
103
  end
@@ -4,6 +4,7 @@ module Labkit
4
4
  module RateLimit
5
5
  # Identifier is a value object wrapping a hash of key-value pairs that
6
6
  # describe the caller (e.g. user, ip, endpoint).
7
+ # Endpoint values are normalised at construction time (query string stripped).
7
8
  class Identifier
8
9
  # Normalize an endpoint value: strip query string.
9
10
  def self.normalize_endpoint(value)
@@ -15,7 +16,9 @@ module Labkit
15
16
  attr_reader :attributes
16
17
 
17
18
  def initialize(attributes = {})
18
- @attributes = attributes.transform_keys(&:to_sym).freeze
19
+ normalised = attributes.transform_keys(&:to_sym)
20
+ normalised[:endpoint] = self.class.normalize_endpoint(normalised[:endpoint]) if normalised.key?(:endpoint)
21
+ @attributes = normalised.freeze
19
22
  end
20
23
 
21
24
  # Return the value for a characteristic key.
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "labkit/logging/json_logger"
4
+
5
+ module Labkit
6
+ module RateLimit
7
+ # Limiter is the primary public API for rate limiting.
8
+ # Instantiate once per call site (e.g. at application boot), then call
9
+ # #check(identifier) on every request. The internal Evaluator is reused
10
+ # across calls, avoiding per-request object allocation.
11
+ #
12
+ # @example
13
+ # limiter = Labkit::RateLimit::Limiter.new(
14
+ # name: "rack_request",
15
+ # rules: [Labkit::RateLimit::Rule.new(name: "api_user", limit: 100, period: 60, characteristics: [:user])]
16
+ # )
17
+ # result = limiter.check({ user: 42, ip: "1.2.3.4" })
18
+ # render_429 if result.exceeded? && result.action == :block
19
+ class Limiter
20
+ NAME_PATTERN = /\A[a-z0-9_]+\z/
21
+
22
+ def initialize(name:, rules:, redis: nil, logger: nil)
23
+ @logger = logger || RateLimit.config.logger || Labkit::Logging::JsonLogger.new($stdout)
24
+ validated_name = validate_name!(name)
25
+ @name = validated_name
26
+
27
+ @evaluator = Evaluator.new(
28
+ name: validated_name,
29
+ rules: prepare_rules(rules),
30
+ redis: redis || RateLimit.config.redis,
31
+ logger: @logger
32
+ )
33
+ end
34
+
35
+ # @param identifier [Identifier, Hash] caller attributes for this request
36
+ # @return [Result]
37
+ def check(identifier)
38
+ id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
39
+ result = @evaluator.check(id)
40
+
41
+ if result.exceeded? && result.action == :block
42
+ @logger.warn(
43
+ message: "rate_limit_check",
44
+ name: @name,
45
+ rule_name: result.rule.name,
46
+ exceeded: true,
47
+ severity: "WARN"
48
+ )
49
+ end
50
+
51
+ result
52
+ end
53
+
54
+ private
55
+
56
+ def validate_name!(name)
57
+ raise ArgumentError, "name must be a non-empty String" unless name.is_a?(String) && !name.empty?
58
+ return name if NAME_PATTERN.match?(name)
59
+
60
+ raise ArgumentError, "Invalid name: #{name.inspect}. Must match /\\A[a-z0-9_]+\\z/" if Labkit.dev_or_test?
61
+
62
+ sanitized = name.gsub(/[^a-z0-9_]/, "_")
63
+ @logger.warn(message: "rate_limit_invalid_name", name: name, sanitized: sanitized)
64
+ sanitized
65
+ end
66
+
67
+ # Validates and deduplicates rule names before passing rules to Evaluator.
68
+ # In dev/test: raises on invalid format or duplicate names.
69
+ # In production: sanitizes invalid names (WARN) and drops duplicates (WARN, first wins).
70
+ # Returns an array of rules with sanitized names.
71
+ def prepare_rules(rules)
72
+ seen = {}
73
+ rules.each_with_index.filter_map do |rule, idx|
74
+ sanitized = sanitize_rule_name(rule.name)
75
+
76
+ if seen.key?(sanitized)
77
+ raise ArgumentError, "Duplicate rule name #{sanitized.inspect} at index #{idx}" if Labkit.dev_or_test?
78
+
79
+ @logger.warn(
80
+ message: "rate_limit_duplicate_rule_name",
81
+ name: sanitized,
82
+ dropped_occurrence: idx
83
+ )
84
+ next nil
85
+ end
86
+
87
+ seen[sanitized] = true
88
+ sanitized == rule.name ? rule : rule.with(name: sanitized) # rubocop:disable CodeReuse/ActiveRecord
89
+ end
90
+ end
91
+
92
+ def sanitize_rule_name(name)
93
+ s = name.to_s
94
+ return s if RULE_NAME_PATTERN.match?(s) && s.length <= RULE_NAME_MAX_LENGTH
95
+
96
+ sanitized = s.downcase.gsub(/[^a-z0-9_]/, "_")[0, RULE_NAME_MAX_LENGTH]
97
+ sanitized = "unnamed_rule" if sanitized.empty?
98
+ @logger.warn(
99
+ message: "rate_limit_invalid_rule_name",
100
+ original_name: s,
101
+ sanitized_name: sanitized
102
+ )
103
+ sanitized
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
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 - :block or :log (nil when matched? is false)
9
+ # rule - the matched Rule object (nil when matched? is false)
10
+ # error? - true if Redis was unavailable; result fails open (exceeded? is false)
11
+ Result = Data.define(:matched, :exceeded, :action, :rule, :error) do
12
+ def initialize(matched:, exceeded: false, action: nil, rule: nil, error: false)
13
+ super
14
+ end
15
+
16
+ def matched?
17
+ matched
18
+ end
19
+
20
+ def exceeded?
21
+ exceeded
22
+ end
23
+
24
+ def error?
25
+ error
26
+ end
27
+ end
28
+ end
29
+ end
@@ -2,14 +2,43 @@
2
2
 
3
3
  module Labkit
4
4
  module RateLimit
5
+ KNOWN_ACTIONS = [:block, :log].freeze
6
+ RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
7
+ RULE_NAME_MAX_LENGTH = 64
8
+
5
9
  # Rule is a value object describing a single rate limit rule.
6
- Rule = Data.define(:match, :limit, :period, :action, :characteristics) do
7
- def initialize(limit:, period:, characteristics:, match: {}, action: :block)
10
+ # name - stable identifier used in Redis keys and log entries
11
+ # match - hash of identifier key/value pairs that must all match for
12
+ # the rule to apply; empty hash matches any identifier
13
+ # limit - request threshold; may be a callable (resolved per check)
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)
16
+ # characteristics - identifier keys used to build the compound Redis counter key
17
+ #
18
+ # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
19
+ # characters. It is used as the middle segment of every Redis counter key for
20
+ # this rule, so changing a rule's name mid-window abandons its in-flight counters.
21
+ Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics) do
22
+ def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block)
23
+ raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
24
+
25
+ name_str = name.to_s
26
+ raise ArgumentError, "name must not be empty" if name_str.empty?
27
+
28
+ action_sym = action.to_sym
29
+ raise ArgumentError, "Invalid action: #{action.inspect}. Must be one of: #{KNOWN_ACTIONS.inspect}" unless KNOWN_ACTIONS.include?(action_sym)
30
+
31
+ if Labkit.dev_or_test?
32
+ raise ArgumentError, "Invalid rule name: #{name.inspect}. Must match /\\A[a-z0-9_]+\\z/" unless RULE_NAME_PATTERN.match?(name_str)
33
+ raise ArgumentError, "Rule name too long: #{name.inspect}. Maximum 64 characters" if name_str.length > RULE_NAME_MAX_LENGTH
34
+ end
35
+
8
36
  super(
37
+ name: name_str.freeze,
9
38
  match: match.transform_keys(&:to_sym).freeze,
10
39
  limit: limit,
11
40
  period: period,
12
- action: action.to_sym,
41
+ action: action_sym,
13
42
  characteristics: Array(characteristics).map(&:to_sym).freeze
14
43
  )
15
44
  end
@@ -1,34 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Labkit
4
- # RateLimit provides a simple rules-based rate limiting API backed by Redis counters.
4
+ # RateLimit provides a rules-based rate limiting API backed by Redis counters.
5
+ # Primary usage: instantiate a Limiter once per call site and reuse it.
6
+ #
7
+ # @example Configuration (e.g. in a Rails initializer)
8
+ # Labkit::RateLimit.configure do |c|
9
+ # c.redis = Redis.current
10
+ # c.logger = Labkit::Logging::JsonLogger.new($stdout)
11
+ # end
12
+ #
13
+ # @example Per-call-site setup
14
+ # RACK_LIMITER = Labkit::RateLimit::Limiter.new(
15
+ # name: "rack_request",
16
+ # rules: [...]
17
+ # )
18
+ # result = RACK_LIMITER.check(identifier)
5
19
  module RateLimit
20
+ autoload :Configuration, "labkit/rate_limit/configuration"
6
21
  autoload :Identifier, "labkit/rate_limit/identifier"
22
+ autoload :Result, "labkit/rate_limit/result"
7
23
  autoload :Rule, "labkit/rate_limit/rule"
8
24
  autoload :Evaluator, "labkit/rate_limit/evaluator"
25
+ autoload :Limiter, "labkit/rate_limit/limiter"
9
26
 
10
- # Defined independently to avoid forcing eager load of Evaluator at module load time.
11
- # Must stay in sync with Evaluator::KNOWN_CHARACTERISTICS.
12
- KNOWN_CHARACTERISTICS = [:user, :ip, :namespace, :plan, :endpoint].freeze
27
+ class << self
28
+ def configure
29
+ yield config
30
+ end
13
31
 
14
- # Check whether the given call_site + identifier combination is within the
15
- # configured rules.
16
- #
17
- # @param call_site [String] machine-readable name of the call site
18
- # @param identifier [Identifier, Hash] caller attributes
19
- # @param rules [Array<Rule>] ordered list of rate limit rules
20
- # @param redis [Object] Redis client (must respond to #incr and #expire)
21
- # @param logger [Logger, nil] optional logger override
22
- # @return [:allow, :block]
23
- def self.check(call_site:, identifier:, rules:, redis:, logger: nil)
24
- id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
25
- Evaluator.new(
26
- call_site: call_site,
27
- identifier: id,
28
- rules: rules,
29
- redis: redis,
30
- logger: logger
31
- ).evaluate
32
+ def config
33
+ @config ||= Configuration.new
34
+ end
35
+
36
+ # Convenience wrapper - creates a throw-away Limiter.
37
+ # Prefer Limiter for call sites that can cache the object.
38
+ #
39
+ # @param name [String] call site name
40
+ # @param identifier [Identifier, Hash] caller attributes
41
+ # @param rules [Array<Rule>] ordered list of rules (first match wins)
42
+ # @param redis [Object, nil] Redis client; falls back to config.redis
43
+ # @param logger [Logger, nil] logger; falls back to config.logger
44
+ # @return [Result]
45
+ def check(name:, identifier:, rules:, redis: nil, logger: nil)
46
+ Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier)
47
+ end
32
48
  end
33
49
  end
34
50
  end
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.13.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -599,8 +599,11 @@ files:
599
599
  - lib/labkit/middleware/sidekiq/user_experience_sli/server.rb
600
600
  - lib/labkit/net_http_publisher.rb
601
601
  - lib/labkit/rate_limit.rb
602
+ - lib/labkit/rate_limit/configuration.rb
602
603
  - lib/labkit/rate_limit/evaluator.rb
603
604
  - lib/labkit/rate_limit/identifier.rb
605
+ - lib/labkit/rate_limit/limiter.rb
606
+ - lib/labkit/rate_limit/result.rb
604
607
  - lib/labkit/rate_limit/rule.rb
605
608
  - lib/labkit/rspec/README.md
606
609
  - lib/labkit/rspec/matchers.rb