gitlab-labkit 1.12.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: e99cf18b21d9da22b488d0dd0031fc9030aeded745239b4e72e8d3f7c1c36555
4
- data.tar.gz: 664ba45d3535d3310431eece7890c58ddfa9cd8a9dfa3c235b66fd20b2b5480e
3
+ metadata.gz: b563d434908c8c7473aeebf5be13bf8f1f6d4f9634cbbdb389581bb5c4b14953
4
+ data.tar.gz: 0b004077e12eb342c449856c7eadfcae558ea78b024eca86f4686cb7b06ef0b1
5
5
  SHA512:
6
- metadata.gz: f7959c2eb8f54ee928a7841c2b8de8011c1e2a481c3c6aaf8e94b53a7ca186e8c28ad192a641c4c8ffac5bd8fd39a2c7c6afad324dfae77531b00e3089de3dc0
7
- data.tar.gz: 05d07f6b1dffe39907c372ca0cb1c638cbc3898f39faf33f82ef5e042c9ea916a94d297963706e0010bc30dc14df2ebf2a1d89a342866630753af233f52934d1
6
+ metadata.gz: 03cd36c29407a01003104a8893d4afefdf452875e286b7535590ddae7febabf8fede9193de3946ac25008b11a3210b7a4454fd85638c654270039b94383cfb01
7
+ data.tar.gz: c9679d0fcb0aa67389801afeabc0fe1e625193f77eab5d41911451df82145a26e020761132ae0a1646b3e3720fbc7f7743d420f7ad1745c5d8f88443151ee777
data/.copier-answers.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.45.0
6
+ _commit: v1.46.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
9
  golang: false
data/CODEOWNERS CHANGED
@@ -1,4 +1,4 @@
1
1
  # CODEOWNERS is used to lookup assignees for
2
2
  # Renovate Bot dependency change Merge Requests.
3
3
  # https://docs.renovatebot.com/configuration-options/#assigneesfromcodeowners
4
- * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @e_forbes @M_Alvarez
4
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @e_forbes @M_Alvarez @mwoolf
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"
@@ -18,6 +24,7 @@ module Labkit
18
24
  autoload :Metrics, "labkit/metrics"
19
25
  autoload :Middleware, "labkit/middleware"
20
26
  autoload :Fields, "labkit/fields"
27
+ autoload :RateLimit, "labkit/rate_limit"
21
28
 
22
29
  # Publishers to publish notifications whenever a HTTP reqeust is made.
23
30
  # A broadcasted notification's payload in topic "request.external_http" includes:
@@ -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
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Labkit
6
+ module RateLimit
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
10
+ class Evaluator
11
+ REDIS_KEY_PREFIX = "labkit:rl"
12
+ CHAR_VALUE_MAX_LENGTH = 200
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
20
+ end
21
+
22
+ def check(identifier)
23
+ check_rules(identifier)
24
+ rescue StandardError => e
25
+ # Intentionally broad: fail-open applies to any unexpected error (network,
26
+ # timeout, OOM) not only Redis protocol errors.
27
+ log_error(e, identifier)
28
+ Result.new(matched: false, error: true)
29
+ end
30
+
31
+ private
32
+
33
+ def check_rules(identifier)
34
+ @rules.each do |rule|
35
+ next unless rule_matches?(rule, identifier)
36
+
37
+ return evaluate_rule(rule, identifier)
38
+ end
39
+
40
+ Result.new(matched: false)
41
+ end
42
+
43
+ def rule_matches?(rule, identifier)
44
+ rule.match.all? { |key, value| identifier[key] == value }
45
+ end
46
+
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))
51
+
52
+ count = incr_with_ttl(redis_key, resolved_period)
53
+ exceeded = count > resolved_limit
54
+
55
+ Result.new(matched: true, exceeded: exceeded, action: rule.action, rule: rule)
56
+ end
57
+
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)}"
63
+ end
64
+ key
65
+ end
66
+
67
+ def resolve_char_value(char, identifier)
68
+ value = identifier[char]
69
+ return MISSING_VALUE_SENTINEL if value.nil? || value.to_s.empty?
70
+
71
+ value.to_s
72
+ end
73
+
74
+ def resolve_value(val)
75
+ val.respond_to?(:call) ? val.call : val
76
+ end
77
+
78
+ def encode_char_value(value)
79
+ if value.length > CHAR_VALUE_MAX_LENGTH
80
+ OpenSSL::Digest::SHA256.hexdigest(value)
81
+ else
82
+ value
83
+ end
84
+ end
85
+
86
+ def incr_with_ttl(redis_key, period)
87
+ count = @redis.incr(redis_key)
88
+ # Set expiry only on first write to avoid resetting TTL on each call
89
+ @redis.expire(redis_key, period) if count == 1
90
+ count
91
+ end
92
+
93
+ def log_error(error, identifier)
94
+ @logger.warn(
95
+ message: "rate_limit_error",
96
+ name: @name,
97
+ error: error.class.to_s,
98
+ identifier: identifier&.to_h
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ # Identifier is a value object wrapping a hash of key-value pairs that
6
+ # describe the caller (e.g. user, ip, endpoint).
7
+ # Endpoint values are normalised at construction time (query string stripped).
8
+ class Identifier
9
+ # Normalize an endpoint value: strip query string.
10
+ def self.normalize_endpoint(value)
11
+ return value unless value.is_a?(String)
12
+
13
+ value.split("?", 2).first
14
+ end
15
+
16
+ attr_reader :attributes
17
+
18
+ def initialize(attributes = {})
19
+ normalised = attributes.transform_keys(&:to_sym)
20
+ normalised[:endpoint] = self.class.normalize_endpoint(normalised[:endpoint]) if normalised.key?(:endpoint)
21
+ @attributes = normalised.freeze
22
+ end
23
+
24
+ # Return the value for a characteristic key.
25
+ def [](key)
26
+ @attributes[key.to_sym]
27
+ end
28
+
29
+ # Serialize to a plain Hash suitable for JSON logging.
30
+ def to_h
31
+ @attributes.transform_keys(&:to_s)
32
+ end
33
+
34
+ def ==(other)
35
+ other.is_a?(Identifier) && other.attributes == @attributes
36
+ end
37
+ end
38
+ end
39
+ end
@@ -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
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
4
+ module RateLimit
5
+ # Rule is a value object describing a single rate limit rule.
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)
15
+ super(
16
+ name: name.to_s.tr(":", "_"),
17
+ match: match.transform_keys(&:to_sym).freeze,
18
+ limit: limit,
19
+ period: period,
20
+ action: action.to_sym,
21
+ characteristics: Array(characteristics).map(&:to_sym).freeze
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labkit
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)
19
+ module RateLimit
20
+ autoload :Configuration, "labkit/rate_limit/configuration"
21
+ autoload :Identifier, "labkit/rate_limit/identifier"
22
+ autoload :Result, "labkit/rate_limit/result"
23
+ autoload :Rule, "labkit/rate_limit/rule"
24
+ autoload :Evaluator, "labkit/rate_limit/evaluator"
25
+ autoload :Limiter, "labkit/rate_limit/limiter"
26
+
27
+ class << self
28
+ def configure
29
+ yield config
30
+ end
31
+
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
48
+ end
49
+ end
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.12.0
4
+ version: 1.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -598,6 +598,13 @@ files:
598
598
  - lib/labkit/middleware/sidekiq/user_experience_sli/client.rb
599
599
  - lib/labkit/middleware/sidekiq/user_experience_sli/server.rb
600
600
  - lib/labkit/net_http_publisher.rb
601
+ - lib/labkit/rate_limit.rb
602
+ - lib/labkit/rate_limit/configuration.rb
603
+ - lib/labkit/rate_limit/evaluator.rb
604
+ - lib/labkit/rate_limit/identifier.rb
605
+ - lib/labkit/rate_limit/limiter.rb
606
+ - lib/labkit/rate_limit/result.rb
607
+ - lib/labkit/rate_limit/rule.rb
601
608
  - lib/labkit/rspec/README.md
602
609
  - lib/labkit/rspec/matchers.rb
603
610
  - lib/labkit/rspec/matchers/user_experience_matchers.rb