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 +4 -4
- data/.gitlab-ci.yml +3 -3
- data/.pre-commit-config.yaml +1 -1
- data/lib/labkit/rate_limit/evaluator.rb +50 -1
- data/lib/labkit/rate_limit/limiter.rb +19 -0
- data/lib/labkit/rate_limit/matcher.rb +97 -0
- data/lib/labkit/rate_limit/rule.rb +1 -1
- data/lib/labkit/rate_limit.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb897d7171414d685d81aa2d55930e9633a46ac6aee0bdca4f36ed34b38e307e
|
|
4
|
+
data.tar.gz: bd171795616da76cf4262da0f70d6a4e3746bea4e5eab74b3fe554d3597a0c87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
data/.pre-commit-config.yaml
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
data/lib/labkit/rate_limit.rb
CHANGED
|
@@ -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.
|
|
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
|