gitlab-labkit 1.13.0 → 1.14.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: b563d434908c8c7473aeebf5be13bf8f1f6d4f9634cbbdb389581bb5c4b14953
4
+ data.tar.gz: 0b004077e12eb342c449856c7eadfcae558ea78b024eca86f4686cb7b06ef0b1
5
5
  SHA512:
6
- metadata.gz: 7292067a1a5d8e231776b65b381ba161a2123c6363df5ebd4269ae65b596871ebddb5bd610e5ab33da0939cdeab4639a51bcb2636046cfd8cb941ac6f6baa9bb
7
- data.tar.gz: 771b7fded81f969ee414ef737b74fa9c22d620762f0e56c669e89de9c4ebf6c022fdbf91513fe630a580acfc8408d20a0eb55e3f05e0c97c412ab6ab818a75bf
6
+ metadata.gz: 03cd36c29407a01003104a8893d4afefdf452875e286b7535590ddae7febabf8fede9193de3946ac25008b11a3210b7a4454fd85638c654270039b94383cfb01
7
+ data.tar.gz: c9679d0fcb0aa67389801afeabc0fe1e625193f77eab5d41911451df82145a26e020761132ae0a1646b3e3720fbc7f7743d420f7ad1745c5d8f88443151ee777
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,55 @@
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
+ resolved_logger = logger || RateLimit.config.logger || Labkit::Logging::JsonLogger.new($stdout)
24
+ validated_name = validate_name!(name, resolved_logger)
25
+
26
+ @evaluator = Evaluator.new(
27
+ name: validated_name,
28
+ rules: rules,
29
+ redis: redis || RateLimit.config.redis,
30
+ logger: resolved_logger
31
+ )
32
+ end
33
+
34
+ # @param identifier [Identifier, Hash] caller attributes for this request
35
+ # @return [Result]
36
+ def check(identifier)
37
+ id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
38
+ @evaluator.check(id)
39
+ end
40
+
41
+ private
42
+
43
+ def validate_name!(name, logger)
44
+ raise ArgumentError, "name must be a non-empty String" unless name.is_a?(String) && !name.empty?
45
+ return name if NAME_PATTERN.match?(name)
46
+
47
+ raise ArgumentError, "Invalid name: #{name.inspect}. Must match /\\A[a-z0-9_]+\\z/" if Labkit.dev_or_test?
48
+
49
+ sanitized = name.gsub(/[^a-z0-9_]/, "_")
50
+ logger.warn(message: "rate_limit_invalid_name", name: name, sanitized: sanitized)
51
+ sanitized
52
+ end
53
+ end
54
+ end
55
+ 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
@@ -3,9 +3,17 @@
3
3
  module Labkit
4
4
  module RateLimit
5
5
  # 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)
6
+ # name - stable identifier used in Redis keys and log entries
7
+ # match - hash of identifier key/value pairs that must all match for
8
+ # the rule to apply; empty hash matches any identifier
9
+ # limit - request threshold; may be a callable (resolved per check)
10
+ # period - window in seconds; may be a callable (resolved per check)
11
+ # action - :block (enforce) or :log (count and log, but do not block)
12
+ # characteristics - identifier keys used to build the compound Redis counter key
13
+ Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics) do
14
+ def initialize(name:, limit:, period:, characteristics:, match: {}, action: :block)
8
15
  super(
16
+ name: name.to_s.tr(":", "_"),
9
17
  match: match.transform_keys(&:to_sym).freeze,
10
18
  limit: limit,
11
19
  period: period,
@@ -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.14.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