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 +4 -4
- data/.gitlab-ci.yml +3 -3
- data/.pre-commit-config.yaml +1 -1
- data/lib/labkit/rate_limit/evaluator.rb +68 -11
- data/lib/labkit/rate_limit/limiter.rb +19 -0
- data/lib/labkit/rate_limit/matcher.rb +97 -0
- data/lib/labkit/rate_limit/result.rb +34 -16
- 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)
|
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
97
|
-
|
|
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?
|
|
7
|
-
# exceeded?
|
|
8
|
-
# action
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# rule
|
|
15
|
-
# error?
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
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
|
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
|