gitlab-labkit 2.8.1 → 4.6.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/CODEOWNERS +1 -1
- data/.gitlab-ci-asdf-versions.yml +1 -1
- data/.gitlab-ci.yml +3 -3
- data/.pre-commit-config.yaml +1 -1
- data/.tool-versions +1 -1
- data/CODEOWNERS +1 -1
- data/lib/labkit/fields.rb +8 -1
- data/lib/labkit/rate_limit/README.md +279 -74
- data/lib/labkit/rate_limit/evaluator.rb +339 -80
- data/lib/labkit/rate_limit/identifier.rb +26 -1
- data/lib/labkit/rate_limit/limiter.rb +24 -3
- data/lib/labkit/rate_limit/matcher.rb +69 -9
- data/lib/labkit/rate_limit/metrics.rb +62 -3
- data/lib/labkit/rate_limit/result.rb +133 -19
- data/lib/labkit/rate_limit/rule.rb +42 -11
- data/lib/labkit/rate_limit.rb +2 -1
- metadata +2 -2
|
@@ -15,7 +15,7 @@ module Labkit
|
|
|
15
15
|
# rules: [Labkit::RateLimit::Rule.new(name: "api_user", limit: 100, period: 60, characteristics: [:user])]
|
|
16
16
|
# )
|
|
17
17
|
# result = limiter.check({ user: 42, ip: "1.2.3.4" })
|
|
18
|
-
# render_429 if result.
|
|
18
|
+
# render_429 if result.action == :block
|
|
19
19
|
class Limiter
|
|
20
20
|
NAME_PATTERN = /\A[a-z0-9_]+\z/
|
|
21
21
|
|
|
@@ -45,7 +45,7 @@ module Labkit
|
|
|
45
45
|
# the rule definition and the call site colocated.
|
|
46
46
|
# @return [Result]
|
|
47
47
|
def check(identifier, cost: 1, rule_context: nil)
|
|
48
|
-
id =
|
|
48
|
+
id = to_identifier(identifier)
|
|
49
49
|
@evaluator.check(id, cost: cost, rule_context: rule_context)
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -65,12 +65,33 @@ module Labkit
|
|
|
65
65
|
# @param rule_context [Hash, nil] see {#check}
|
|
66
66
|
# @return [Result]
|
|
67
67
|
def peek(identifier, rule_context: nil)
|
|
68
|
-
id =
|
|
68
|
+
id = to_identifier(identifier)
|
|
69
69
|
@evaluator.peek(id, rule_context: rule_context)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Discards this limiter's state for one identifier: every rule's counter,
|
|
73
|
+
# and any ban written by a rule carrying ban_for.
|
|
74
|
+
#
|
|
75
|
+
# For call sites where a later success should wipe earlier failures, such
|
|
76
|
+
# as an authentication ban cleared by a valid login. Counters otherwise
|
|
77
|
+
# only expire with their window; this is the only way to end one early.
|
|
78
|
+
#
|
|
79
|
+
# Scoped to this limiter and identifier, not to a single rule: a caller
|
|
80
|
+
# clearing after a success knows who succeeded, not which rules matched.
|
|
81
|
+
#
|
|
82
|
+
# @param identifier [Identifier, Hash] caller attributes
|
|
83
|
+
# @return [Integer] number of Redis keys removed; on a Redis error, those
|
|
84
|
+
# removed before it failed
|
|
85
|
+
def clear(identifier)
|
|
86
|
+
@evaluator.clear(to_identifier(identifier))
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
private
|
|
73
90
|
|
|
91
|
+
def to_identifier(identifier)
|
|
92
|
+
identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
|
|
93
|
+
end
|
|
94
|
+
|
|
74
95
|
def validate_name!(name)
|
|
75
96
|
raise ArgumentError, "name must be a non-empty String" unless name.is_a?(String) && !name.empty?
|
|
76
97
|
return name if NAME_PATTERN.match?(name)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module Labkit
|
|
4
6
|
module RateLimit
|
|
5
7
|
# Matcher is the internal representation of a single key/value predicate in
|
|
@@ -11,6 +13,14 @@ module Labkit
|
|
|
11
13
|
# - a Regexp instance -> :re matcher (Ruby convenience)
|
|
12
14
|
# - { eq: <value> } -> :eq matcher (canonical, YAML-compatible)
|
|
13
15
|
# - { re: <String|Regexp> } -> :re matcher (canonical, YAML-compatible)
|
|
16
|
+
# - { oneOf: <Array> } -> :oneOf matcher (set membership, YAML-compatible)
|
|
17
|
+
#
|
|
18
|
+
# A bare Array is rejected rather than treated as :oneOf, so a value that
|
|
19
|
+
# was meant as a single matcher hash but arrived as an Array fails loudly
|
|
20
|
+
# instead of silently becoming a membership test.
|
|
21
|
+
#
|
|
22
|
+
# :oneOf membership is Set#include? (eql?/hash equality), not :eq's ==, so
|
|
23
|
+
# cross-type numerics differ (1 == 1.0, but {oneOf: [1]} does not match 1.0).
|
|
14
24
|
#
|
|
15
25
|
# Hash-key naming follows the metrics-catalog selector pattern. Glob,
|
|
16
26
|
# prefix, and other matcher kinds are intentionally out of scope here; see
|
|
@@ -20,29 +30,38 @@ module Labkit
|
|
|
20
30
|
# the regex, so callers can match non-String identifier values such as
|
|
21
31
|
# Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
|
|
22
32
|
class Matcher < Data.define(:type, :value)
|
|
23
|
-
KNOWN_HASH_KEYS = %i[eq re].freeze
|
|
33
|
+
KNOWN_HASH_KEYS = %i[eq re oneOf].freeze
|
|
24
34
|
MAX_REGEX_SOURCE_LENGTH = 200
|
|
25
35
|
ERROR_INSPECT_LIMIT = 80
|
|
26
36
|
|
|
37
|
+
# Wall-clock budget for a single #match? call. Without it a match is
|
|
38
|
+
# bounded only by whatever global the host sets (40s in GitLab Rails,
|
|
39
|
+
# unbounded elsewhere), and a timeout reaches Evaluator's fail-open
|
|
40
|
+
# rescue, so the request goes unlimited.
|
|
41
|
+
#
|
|
42
|
+
# 50ms is a safety net against catastrophic backtracking, not a
|
|
43
|
+
# performance bound: real matches are far cheaper (median 0.087ms, worst
|
|
44
|
+
# 1.484ms across 6935 paths that timed out in production at 5ms). Those
|
|
45
|
+
# timeouts were wall-clock stalls, since Ruby counts real time and GC
|
|
46
|
+
# pauses on GitLab's git fleet have a p50 of ~72ms. Under 4x CPU
|
|
47
|
+
# oversubscription 50ms still fires on 0.16% of those matches, 5ms 1.33%.
|
|
48
|
+
MATCH_TIMEOUT_SECONDS = 0.05
|
|
49
|
+
|
|
27
50
|
def self.build(input)
|
|
28
51
|
case input
|
|
29
52
|
when Regexp
|
|
30
|
-
new(type: :re, value: input)
|
|
53
|
+
new(type: :re, value: with_match_timeout(input))
|
|
31
54
|
when Hash
|
|
32
55
|
from_hash(input)
|
|
33
56
|
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)}"
|
|
57
|
+
raise ArgumentError, invalid_shape_error(input)
|
|
36
58
|
else
|
|
37
59
|
new(type: :eq, value: input)
|
|
38
60
|
end
|
|
39
61
|
end
|
|
40
62
|
|
|
41
63
|
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
|
|
64
|
+
raise ArgumentError, invalid_shape_error(input) if input.size != 1
|
|
46
65
|
|
|
47
66
|
type, source = input.first
|
|
48
67
|
type_sym = type.to_sym
|
|
@@ -60,6 +79,13 @@ module Labkit
|
|
|
60
79
|
case type_sym
|
|
61
80
|
when :eq
|
|
62
81
|
new(type: :eq, value: source)
|
|
82
|
+
when :oneOf
|
|
83
|
+
unless source.is_a?(Array)
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"rate-limit match value {oneOf: ...} must be an Array, got #{truncate_for_error(source)}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
new(type: :oneOf, value: Set.new(source).freeze)
|
|
63
89
|
when :re
|
|
64
90
|
if source.to_s.length > MAX_REGEX_SOURCE_LENGTH
|
|
65
91
|
raise ArgumentError,
|
|
@@ -67,7 +93,7 @@ module Labkit
|
|
|
67
93
|
end
|
|
68
94
|
|
|
69
95
|
begin
|
|
70
|
-
new(type: :re, value:
|
|
96
|
+
new(type: :re, value: with_match_timeout(source))
|
|
71
97
|
rescue RegexpError, TypeError => e
|
|
72
98
|
raise ArgumentError,
|
|
73
99
|
"rate-limit match value {re: #{truncate_for_error(source)}} failed to compile: #{e.message}"
|
|
@@ -76,6 +102,38 @@ module Labkit
|
|
|
76
102
|
end
|
|
77
103
|
private_class_method :compile
|
|
78
104
|
|
|
105
|
+
# Compiles +source+ (a String pattern or an existing Regexp) into a
|
|
106
|
+
# Regexp bounded by MATCH_TIMEOUT_SECONDS.
|
|
107
|
+
#
|
|
108
|
+
# The timeout is set per-Regexp rather than via the global
|
|
109
|
+
# +Regexp.timeout=+: labkit is a library, and a global would silently
|
|
110
|
+
# change regex behaviour throughout the host application, including
|
|
111
|
+
# code unrelated to rate limiting.
|
|
112
|
+
#
|
|
113
|
+
# A Regexp that already carries its own timeout is returned untouched -
|
|
114
|
+
# an explicit choice by the rule author wins over our default.
|
|
115
|
+
#
|
|
116
|
+
# Recompiling an existing Regexp preserves its source and options
|
|
117
|
+
# (including the fixed-encoding flags) and therefore +#==+; only object
|
|
118
|
+
# identity changes.
|
|
119
|
+
def self.with_match_timeout(source)
|
|
120
|
+
return source if source.is_a?(Regexp) && source.timeout
|
|
121
|
+
|
|
122
|
+
if source.is_a?(Regexp)
|
|
123
|
+
Regexp.new(source.source, source.options, timeout: MATCH_TIMEOUT_SECONDS)
|
|
124
|
+
else
|
|
125
|
+
Regexp.new(source, timeout: MATCH_TIMEOUT_SECONDS)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private_class_method :with_match_timeout
|
|
130
|
+
|
|
131
|
+
def self.invalid_shape_error(input)
|
|
132
|
+
"rate-limit match value must be a single-key Hash like {re: \"...\"}, {eq: ...} or {oneOf: [...]}, " \
|
|
133
|
+
"got #{truncate_for_error(input)}"
|
|
134
|
+
end
|
|
135
|
+
private_class_method :invalid_shape_error
|
|
136
|
+
|
|
79
137
|
def self.truncate_for_error(value)
|
|
80
138
|
s = value.inspect
|
|
81
139
|
s.length > ERROR_INSPECT_LIMIT ? "#{s[0, ERROR_INSPECT_LIMIT]}...(truncated)" : s
|
|
@@ -86,6 +144,8 @@ module Labkit
|
|
|
86
144
|
case type
|
|
87
145
|
when :eq
|
|
88
146
|
value == identifier_value
|
|
147
|
+
when :oneOf
|
|
148
|
+
value.include?(identifier_value)
|
|
89
149
|
when :re
|
|
90
150
|
value.match?(identifier_value.to_s)
|
|
91
151
|
else
|
|
@@ -1,13 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "concurrent-ruby"
|
|
4
|
+
|
|
3
5
|
module Labkit
|
|
4
6
|
module RateLimit
|
|
5
7
|
module Metrics
|
|
8
|
+
# Process-wide once-latch for log_failure; specs reset via make_false.
|
|
9
|
+
FAILURE_LOGGED = Concurrent::AtomicBoolean.new(false)
|
|
10
|
+
|
|
6
11
|
module_function
|
|
7
12
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
13
|
+
# Metric emission must never affect the caller's outcome. Resolving the
|
|
14
|
+
# metric by name keeps a raising getter inside the rescue.
|
|
15
|
+
def safe_increment(counter, labels, logger: nil)
|
|
16
|
+
public_send(counter).increment(**labels) # rubocop:disable GitlabSecurity/PublicSend
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
log_failure(e, counter, labels, logger)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Gauge counterpart of safe_increment.
|
|
22
|
+
def safe_set(gauge, labels, value, logger: nil)
|
|
23
|
+
public_send(gauge).set(labels, value) # rubocop:disable GitlabSecurity/PublicSend
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
log_failure(e, gauge, labels, logger)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# make_true returns true only for the flipping caller, so exactly one
|
|
29
|
+
# warn per process. Never raises - a raise here would defeat the
|
|
30
|
+
# callers' rescues.
|
|
31
|
+
def log_failure(error, metric, labels, logger)
|
|
32
|
+
return unless logger && FAILURE_LOGGED.make_true
|
|
33
|
+
|
|
34
|
+
logger.warn(
|
|
35
|
+
name: labels[:rate_limiter],
|
|
36
|
+
metric: metric.to_s,
|
|
37
|
+
Labkit::Fields::ERROR_TYPE => "rate_limit_metrics_error",
|
|
38
|
+
Labkit::Fields::CLASS_NAME => error.class.to_s,
|
|
39
|
+
Labkit::Fields::ERROR_MESSAGE => error.message
|
|
40
|
+
)
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Emitted exactly once per #check call, including calls that fail open;
|
|
46
|
+
# summing by rate_limiter gives the request rate through the limiter.
|
|
47
|
+
def checks_total
|
|
48
|
+
Labkit::Metrics::Client.counter(
|
|
49
|
+
:gitlab_labkit_rate_limiter_checks_total,
|
|
50
|
+
'Total number of rate limit checks',
|
|
51
|
+
{ rate_limiter: nil, action: nil, matched: nil, error: nil }
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Deprecated: superseded by checks_total and rule_evaluations_total.
|
|
56
|
+
# Emitted unchanged (once per matched rule, rule="unmatched" when none
|
|
57
|
+
# matched) until consumers migrate; will get removed in a follow-up release.
|
|
11
58
|
def calls_total
|
|
12
59
|
Labkit::Metrics::Client.counter(
|
|
13
60
|
:gitlab_labkit_rate_limiter_calls_total,
|
|
@@ -16,6 +63,18 @@ module Labkit
|
|
|
16
63
|
)
|
|
17
64
|
end
|
|
18
65
|
|
|
66
|
+
# Emitted once per *evaluated* rule. Only matched rules are evaluated:
|
|
67
|
+
# Unmatched traffic is checks_total{matched="false"}
|
|
68
|
+
def rule_evaluations_total
|
|
69
|
+
Labkit::Metrics::Client.counter(
|
|
70
|
+
:gitlab_labkit_rate_limiter_rule_evaluations_total,
|
|
71
|
+
'Total number of rate limit rule evaluations',
|
|
72
|
+
{ rate_limiter: nil, rule: nil, action: nil, result: nil }
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deprecated: superseded by checks_total{error="true"}. Still emitted
|
|
77
|
+
# because it remains the only error metric for #peek.
|
|
19
78
|
def errors_total
|
|
20
79
|
Labkit::Metrics::Client.counter(
|
|
21
80
|
:gitlab_labkit_rate_limiter_errors_total,
|
|
@@ -3,34 +3,114 @@
|
|
|
3
3
|
module Labkit
|
|
4
4
|
module RateLimit
|
|
5
5
|
# Result is the return value of Limiter#check.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
6
|
+
#
|
|
7
|
+
# It accumulates one Result::Evaluation per matched-and-counted rule; the
|
|
8
|
+
# reader methods report the single most-constraining evaluation (see
|
|
9
|
+
# #most_constraining). A matched :skip rule or a fail-open error replaces
|
|
10
|
+
# the evaluations as the source of the reported outcome.
|
|
11
|
+
#
|
|
12
|
+
# Result is a mutable accumulator, not a value object: two Results built
|
|
13
|
+
# from the same evaluations are not #==.
|
|
14
|
+
#
|
|
15
|
+
# matched? - true if at least one rule's match conditions were satisfied
|
|
16
|
+
# exceeded? - true if the reported evaluation's counter exceeded its limit
|
|
17
|
+
# action - the outcome: what the caller should do
|
|
18
|
+
# :block = a :limit rule matched and is over its limit
|
|
19
|
+
# :allow = everything else, including an exceeded :log rule
|
|
20
|
+
# (visible via exceeded?), a matched :skip rule, no rule
|
|
21
|
+
# matched, and error (fail-open).
|
|
22
|
+
# The rule's configured action is available via rule.action.
|
|
23
|
+
# rule - the reported Rule: the :skip rule when one matched, otherwise
|
|
24
|
+
# the most-constraining evaluated Rule (nil when matched? is
|
|
25
|
+
# false). Other rules may also have matched and been counted;
|
|
26
|
+
# see #evaluations.
|
|
27
|
+
# error? - true if Redis was unavailable; result fails open (exceeded? is false)
|
|
28
|
+
# degraded? - true if a matched count_distinct rule was skipped by the
|
|
29
|
+
# missing-key fail-open path; the check still completed
|
|
30
|
+
# info - Result::Info with per-window counters for the reported rule;
|
|
31
|
+
# nil when matched? is false, error?, or the matched rule is
|
|
32
|
+
# :skip (no counter exists)
|
|
33
|
+
# evaluations - every counted evaluation, in rule declaration order
|
|
34
|
+
class Result
|
|
35
|
+
attr_reader :evaluations
|
|
36
|
+
|
|
37
|
+
def self.error
|
|
38
|
+
new(error: true)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(error: false)
|
|
42
|
+
@evaluations = []
|
|
43
|
+
@skip_rule = nil
|
|
44
|
+
@error = error
|
|
45
|
+
@degraded = false
|
|
46
|
+
@most_constraining = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_evaluation(evaluation)
|
|
50
|
+
@most_constraining = nil
|
|
51
|
+
@evaluations << evaluation
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Records a matched :skip rule: the request is allowed and the skip rule
|
|
56
|
+
# is the one reported, regardless of any evaluations already collected.
|
|
57
|
+
def skip!(rule)
|
|
58
|
+
@skip_rule = rule
|
|
59
|
+
self
|
|
22
60
|
end
|
|
23
61
|
|
|
24
62
|
def matched?
|
|
25
|
-
|
|
63
|
+
skipped? || @evaluations.any?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def skipped?
|
|
67
|
+
!!@skip_rule
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def block?
|
|
71
|
+
!skipped? && @evaluations.any?(&:block?)
|
|
26
72
|
end
|
|
27
73
|
|
|
28
74
|
def exceeded?
|
|
29
|
-
|
|
75
|
+
return false if skipped?
|
|
76
|
+
|
|
77
|
+
!most_constraining.nil? && most_constraining.exceeded?
|
|
30
78
|
end
|
|
31
79
|
|
|
32
80
|
def error?
|
|
33
|
-
error
|
|
81
|
+
@error
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Unlike error?, the check keeps going: the flag marks the outcome as
|
|
85
|
+
# degraded without changing it.
|
|
86
|
+
def degraded!
|
|
87
|
+
@degraded = true
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def degraded?
|
|
92
|
+
@degraded
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def action
|
|
96
|
+
block? ? :block : :allow
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def rule
|
|
100
|
+
@skip_rule || most_constraining&.rule
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def info
|
|
104
|
+
return nil if skipped?
|
|
105
|
+
|
|
106
|
+
most_constraining&.info
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The evaluation with the strongest claim on the outcome; ties keep the
|
|
110
|
+
# earliest-declared rule (min returns the first of tied elements).
|
|
111
|
+
# Memoized; add_evaluation invalidates.
|
|
112
|
+
def most_constraining
|
|
113
|
+
@most_constraining ||= @evaluations.min
|
|
34
114
|
end
|
|
35
115
|
|
|
36
116
|
# Returns RFC-compliant rate limit response headers, or {} when no rule matched or an error occurred.
|
|
@@ -48,6 +128,40 @@ module Labkit
|
|
|
48
128
|
end
|
|
49
129
|
end
|
|
50
130
|
|
|
131
|
+
# The outcome of counting one matched rule.
|
|
132
|
+
# rule - the evaluated Rule
|
|
133
|
+
# exceeded - whether the post-increment count exceeded the resolved limit
|
|
134
|
+
# info - Result::Info with the per-window counters
|
|
135
|
+
#
|
|
136
|
+
# Evaluations order by constraint: a blocking evaluation (a :limit rule over
|
|
137
|
+
# its limit, or one whose ban is still in force) ranks strictly first - it
|
|
138
|
+
# decides the request no matter what any other rule reports - then exceeded
|
|
139
|
+
# ones, then fewest remaining.
|
|
140
|
+
# remaining floors at 0, so an exceeded :log rule ties with one sitting
|
|
141
|
+
# exactly on its limit; ranking exceeded ahead keeps a breach from being
|
|
142
|
+
# hidden by a rule that merely reached its limit.
|
|
143
|
+
Result::Evaluation = Data.define(:rule, :exceeded, :info) do
|
|
144
|
+
include Comparable
|
|
145
|
+
|
|
146
|
+
def exceeded?
|
|
147
|
+
exceeded
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def block?
|
|
151
|
+
exceeded? && rule.action == :limit
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def <=>(other)
|
|
155
|
+
constraint_rank <=> other.constraint_rank
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
protected
|
|
159
|
+
|
|
160
|
+
def constraint_rank
|
|
161
|
+
[block? ? 0 : 1, exceeded? ? 0 : 1, info.remaining]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
51
165
|
# Per-window counter data attached to a matched Result.
|
|
52
166
|
# resolved_limit - the evaluated limit Integer for this rule
|
|
53
167
|
# resolved_period - the evaluated period Integer (seconds) for this rule
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Labkit
|
|
4
4
|
module RateLimit
|
|
5
|
-
KNOWN_ACTIONS = %i[
|
|
5
|
+
KNOWN_ACTIONS = %i[limit log skip].freeze
|
|
6
6
|
RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
|
|
7
7
|
RULE_NAME_MAX_LENGTH = 64
|
|
8
8
|
|
|
@@ -12,13 +12,19 @@ module Labkit
|
|
|
12
12
|
# the rule to apply; empty hash matches any identifier
|
|
13
13
|
# limit - request threshold; may be a callable (resolved per check)
|
|
14
14
|
# period - window in seconds; may be a callable (resolved per check)
|
|
15
|
-
# action - :
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
15
|
+
# action - :limit (enforce; the result blocks when the rule is over
|
|
16
|
+
# its limit, which also terminates evaluation), :log (count
|
|
17
|
+
# and log only, never blocks and never terminates),
|
|
18
|
+
# or :skip (bypass: permit and terminate evaluation on
|
|
19
|
+
# match without counting; performs no Redis operation, so
|
|
20
|
+
# limit, period, characteristics, and count_distinct are
|
|
21
|
+
# inert)
|
|
22
|
+
# ban_for - optional ban duration in seconds. On crossing the limit
|
|
23
|
+
# the rule writes a separate ban key that outlives the
|
|
24
|
+
# counter window, and stops counting while it holds.
|
|
25
|
+
# +action+ still decides who is blocked: :limit enforces
|
|
26
|
+
# the ban, :log records what it would have done. Rejected
|
|
27
|
+
# on :skip. May be a callable, resolved per check.
|
|
22
28
|
# characteristics - identifier keys used to build the compound Redis counter key
|
|
23
29
|
# count_distinct - optional Symbol naming an identifier key. When set, the rule
|
|
24
30
|
# counts the number of distinct values seen for that key within
|
|
@@ -29,7 +35,27 @@ module Labkit
|
|
|
29
35
|
# +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
|
|
30
36
|
# characters. It is used as the middle segment of every Redis counter key for
|
|
31
37
|
# this rule, so changing a rule's name mid-window abandons its in-flight counters.
|
|
32
|
-
Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct) do
|
|
38
|
+
Rule = Data.define(:name, :match, :limit, :period, :action, :characteristics, :count_distinct, :ban_for) do
|
|
39
|
+
# ban_for changes the accounting; action still decides who gets blocked.
|
|
40
|
+
# A :skip rule counts nothing, so it has no limit to cross and no ban to
|
|
41
|
+
# write.
|
|
42
|
+
def self.normalize_ban_for(value, action_sym, count_distinct_sym)
|
|
43
|
+
return nil if value.nil?
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "ban_for is not valid on a :skip rule" if action_sym == :skip
|
|
46
|
+
|
|
47
|
+
# The ban path counts with INCRBYFLOAT, so a SET-backed rule would
|
|
48
|
+
# silently count calls instead of distinct values.
|
|
49
|
+
raise ArgumentError, "ban_for cannot be combined with count_distinct #{count_distinct_sym.inspect}" if count_distinct_sym
|
|
50
|
+
|
|
51
|
+
return value if value.respond_to?(:call)
|
|
52
|
+
# Whole seconds: the ban is written with SET EX, and anything under a
|
|
53
|
+
# second truncates to 0, which Redis rejects.
|
|
54
|
+
return value if value.is_a?(Numeric) && value >= 1
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "ban_for must be a Numeric of at least 1 second or a callable, got #{value.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
33
59
|
def self.normalize_count_distinct(value, characteristics_arr)
|
|
34
60
|
sym =
|
|
35
61
|
case value
|
|
@@ -45,7 +71,10 @@ module Labkit
|
|
|
45
71
|
sym
|
|
46
72
|
end
|
|
47
73
|
|
|
48
|
-
def initialize(
|
|
74
|
+
def initialize(
|
|
75
|
+
name:, limit:, period:, characteristics:,
|
|
76
|
+
match: {}, action: :limit, count_distinct: nil, ban_for: nil
|
|
77
|
+
)
|
|
49
78
|
raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
50
79
|
|
|
51
80
|
name_str = name.to_s
|
|
@@ -60,6 +89,7 @@ module Labkit
|
|
|
60
89
|
end
|
|
61
90
|
|
|
62
91
|
characteristics_arr = Array(characteristics).map(&:to_sym).freeze
|
|
92
|
+
count_distinct_sym = self.class.normalize_count_distinct(count_distinct, characteristics_arr)
|
|
63
93
|
|
|
64
94
|
super(
|
|
65
95
|
name: name_str.freeze,
|
|
@@ -68,7 +98,8 @@ module Labkit
|
|
|
68
98
|
period: period,
|
|
69
99
|
action: action_sym,
|
|
70
100
|
characteristics: characteristics_arr,
|
|
71
|
-
count_distinct:
|
|
101
|
+
count_distinct: count_distinct_sym,
|
|
102
|
+
ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym)
|
|
72
103
|
)
|
|
73
104
|
end
|
|
74
105
|
end
|
data/lib/labkit/rate_limit.rb
CHANGED
|
@@ -40,7 +40,8 @@ module Labkit
|
|
|
40
40
|
#
|
|
41
41
|
# @param name [String] call site name
|
|
42
42
|
# @param identifier [Identifier, Hash] caller attributes
|
|
43
|
-
# @param rules [Array<Rule>] ordered list of rules
|
|
43
|
+
# @param rules [Array<Rule>] ordered list of rules; every matching rule is
|
|
44
|
+
# counted, and the Result reports the most constraining one
|
|
44
45
|
# @param redis [Object, nil] Redis client; falls back to config.redis
|
|
45
46
|
# @param logger [Logger, nil] logger; falls back to config.logger
|
|
46
47
|
# @param cost [Numeric] amount to add to the counter; see Limiter#check
|
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:
|
|
4
|
+
version: 4.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Newdigate
|
|
@@ -716,7 +716,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
716
716
|
- !ruby/object:Gem::Version
|
|
717
717
|
version: '0'
|
|
718
718
|
requirements: []
|
|
719
|
-
rubygems_version: 4.0.
|
|
719
|
+
rubygems_version: 4.0.16
|
|
720
720
|
specification_version: 4
|
|
721
721
|
summary: Instrumentation for GitLab
|
|
722
722
|
test_files: []
|