gitlab-labkit 1.22.0 → 2.0.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/lib/gitlab-labkit.rb +1 -0
- data/lib/labkit/rate_limit/evaluator.rb +50 -17
- data/lib/labkit/rate_limit/limiter.rb +5 -2
- data/lib/labkit/rate_limit/result.rb +10 -5
- data/lib/labkit/rate_limit.rb +3 -2
- data/lib/labkit/redis/script.rb +43 -0
- data/lib/labkit/redis.rb +8 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c6ab89c2e34ad08721cee1e920601a8d6d039bfe8a6552777021714f17d0c74
|
|
4
|
+
data.tar.gz: 6b9e36ac9ce69866890d6df28132d8abd280cf7d3b4ab7b748470864dbf8a70e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af23293cd5d9afe2dd3ba93ca4cd98a6ae5178c04079069e60defff46652266158fc7d74fe335d9345391ee11e89602e0ebf09d1d536fc0f0140426676a23a16
|
|
7
|
+
data.tar.gz: 3ae857f6940bf2b871b123d88c3caf3e12589efe3b2a3733cb723b73cdca347c3bfa8506ecf3c931d0d83d7dd99882bf25a9abe7efa3e85f76e4958b111be312
|
data/lib/gitlab-labkit.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Labkit
|
|
|
25
25
|
autoload :Middleware, "labkit/middleware"
|
|
26
26
|
autoload :Fields, "labkit/fields"
|
|
27
27
|
autoload :RateLimit, "labkit/rate_limit"
|
|
28
|
+
autoload :Redis, "labkit/redis"
|
|
28
29
|
|
|
29
30
|
# Publishers to publish notifications whenever a HTTP reqeust is made.
|
|
30
31
|
# A broadcasted notification's payload in topic "request.external_http" includes:
|
|
@@ -12,6 +12,32 @@ module Labkit
|
|
|
12
12
|
CHAR_VALUE_MAX_LENGTH = 200
|
|
13
13
|
MISSING_VALUE_SENTINEL = "_unknown_"
|
|
14
14
|
|
|
15
|
+
# Atomic increment-with-TTL Lua script. The whole script runs as one
|
|
16
|
+
# operation from Redis's perspective, so there is no window between
|
|
17
|
+
# the increment and EXPIRE that can leak a key without TTL.
|
|
18
|
+
#
|
|
19
|
+
# INCRBYFLOAT serves both count-mode (cost=1, equivalent to INCR for
|
|
20
|
+
# integer-encoded keys) and cost-mode callers, so a single script
|
|
21
|
+
# handles every rule shape. cost=0 also flows through INCRBYFLOAT;
|
|
22
|
+
# Redis treats the result as a no-op on the stored value while
|
|
23
|
+
# still observing the post-state count and TTL we return.
|
|
24
|
+
#
|
|
25
|
+
# ttl_before < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
|
|
26
|
+
# The -1 case shouldn't arise with the atomic script, but self-healing
|
|
27
|
+
# recovers keys left without TTL by any prior bug.
|
|
28
|
+
INCR_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
29
|
+
local ttl = ARGV[1]
|
|
30
|
+
local cost = tonumber(ARGV[2])
|
|
31
|
+
local ttl_before = redis.call('TTL', KEYS[1])
|
|
32
|
+
|
|
33
|
+
local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
|
|
34
|
+
if ttl_before < 0 then
|
|
35
|
+
redis.call('EXPIRE', KEYS[1], ttl)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return {count, redis.call('TTL', KEYS[1])}
|
|
39
|
+
LUA
|
|
40
|
+
|
|
15
41
|
def initialize(name:, rules:, redis:, logger:)
|
|
16
42
|
@name = name
|
|
17
43
|
@rules = rules
|
|
@@ -19,8 +45,8 @@ module Labkit
|
|
|
19
45
|
@logger = logger
|
|
20
46
|
end
|
|
21
47
|
|
|
22
|
-
def check(identifier)
|
|
23
|
-
check_rules(identifier)
|
|
48
|
+
def check(identifier, cost: 1)
|
|
49
|
+
check_rules(identifier, cost)
|
|
24
50
|
rescue StandardError => e
|
|
25
51
|
# Intentionally broad: fail-open applies to any unexpected error (network,
|
|
26
52
|
# timeout, OOM) not only Redis protocol errors.
|
|
@@ -44,11 +70,11 @@ module Labkit
|
|
|
44
70
|
|
|
45
71
|
# :log rules are non-terminating: they emit metrics and continue,
|
|
46
72
|
# so a shadow :log rule cannot disable a following :block rule.
|
|
47
|
-
def check_rules(identifier)
|
|
73
|
+
def check_rules(identifier, cost)
|
|
48
74
|
@rules.each do |rule|
|
|
49
75
|
next unless rule_matches?(rule, identifier)
|
|
50
76
|
|
|
51
|
-
result = evaluate_rule(rule, identifier)
|
|
77
|
+
result = evaluate_rule(rule, identifier, cost)
|
|
52
78
|
report_matched_metrics(result)
|
|
53
79
|
return result unless rule.action == :log
|
|
54
80
|
end
|
|
@@ -74,12 +100,12 @@ module Labkit
|
|
|
74
100
|
rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }
|
|
75
101
|
end
|
|
76
102
|
|
|
77
|
-
def evaluate_rule(rule, identifier)
|
|
103
|
+
def evaluate_rule(rule, identifier, cost)
|
|
78
104
|
redis_key = build_redis_key(rule, identifier)
|
|
79
105
|
resolved_limit = Integer(resolve_value(rule.limit))
|
|
80
106
|
resolved_period = Integer(resolve_value(rule.period))
|
|
81
107
|
|
|
82
|
-
count, ttl = incr_with_ttl(redis_key, resolved_period)
|
|
108
|
+
count, ttl = incr_with_ttl(redis_key, resolved_period, cost)
|
|
83
109
|
build_result(rule, resolved_limit, resolved_period, count, ttl)
|
|
84
110
|
end
|
|
85
111
|
|
|
@@ -133,17 +159,17 @@ module Labkit
|
|
|
133
159
|
end
|
|
134
160
|
end
|
|
135
161
|
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
162
|
+
# Atomically increments the counter by `cost`, sets the TTL on first
|
|
163
|
+
# write, and reads back the post-increment TTL, all in one Redis
|
|
164
|
+
# operation via Lua. See INCR_SCRIPT for the script body.
|
|
165
|
+
#
|
|
166
|
+
# count is parsed as Float because INCRBYFLOAT returns a string-encoded
|
|
167
|
+
# number; the Float is integer-valued when cost is 1, fractional
|
|
168
|
+
# otherwise.
|
|
169
|
+
def incr_with_ttl(redis_key, period, cost)
|
|
140
170
|
@redis.with do |conn|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
pipe.ttl(redis_key)
|
|
144
|
-
end
|
|
145
|
-
conn.expire(redis_key, period) if count == 1
|
|
146
|
-
[count, ttl]
|
|
171
|
+
raw_count, ttl = eval_incr_script(conn, redis_key, period, cost)
|
|
172
|
+
[Float(raw_count), ttl]
|
|
147
173
|
end
|
|
148
174
|
end
|
|
149
175
|
|
|
@@ -151,17 +177,24 @@ module Labkit
|
|
|
151
177
|
# A missing key (GET => nil, TTL => -2) is reported as count=0; the
|
|
152
178
|
# build_result fallback then derives reset_at from the rule period
|
|
153
179
|
# since there is no Redis-side window to read.
|
|
180
|
+
#
|
|
181
|
+
# Float parsing accepts both INCR-stored ("5") and INCRBYFLOAT-stored
|
|
182
|
+
# ("5.7") values uniformly.
|
|
154
183
|
def read_with_ttl(redis_key)
|
|
155
184
|
@redis.with do |conn|
|
|
156
185
|
raw_count, ttl = conn.pipelined do |pipe|
|
|
157
186
|
pipe.get(redis_key)
|
|
158
187
|
pipe.ttl(redis_key)
|
|
159
188
|
end
|
|
160
|
-
count = raw_count.nil? ? 0 :
|
|
189
|
+
count = raw_count.nil? ? 0.0 : Float(raw_count)
|
|
161
190
|
[count, ttl]
|
|
162
191
|
end
|
|
163
192
|
end
|
|
164
193
|
|
|
194
|
+
def eval_incr_script(conn, redis_key, period, cost)
|
|
195
|
+
INCR_SCRIPT.eval(conn, keys: [redis_key], argv: [period, cost])
|
|
196
|
+
end
|
|
197
|
+
|
|
165
198
|
def log_error(error, identifier)
|
|
166
199
|
@logger.warn(
|
|
167
200
|
message: "rate_limit_error",
|
|
@@ -32,10 +32,13 @@ module Labkit
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# @param identifier [Identifier, Hash] caller attributes for this request
|
|
35
|
+
# @param cost [Numeric] amount to add to the counter. Defaults to 1
|
|
36
|
+
# (count-mode). Pass a non-1 Numeric for cost-mode counters such as
|
|
37
|
+
# resource-usage limits; passing 0 reads the counter without writing.
|
|
35
38
|
# @return [Result]
|
|
36
|
-
def check(identifier)
|
|
39
|
+
def check(identifier, cost: 1)
|
|
37
40
|
id = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
|
|
38
|
-
@evaluator.check(id)
|
|
41
|
+
@evaluator.check(id, cost: cost)
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
# Read the current rate-limit state without incrementing the counter.
|
|
@@ -33,13 +33,14 @@ module Labkit
|
|
|
33
33
|
|
|
34
34
|
# Returns RFC-compliant rate limit response headers, or {} when no rule matched or an error occurred.
|
|
35
35
|
# Keys: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (Unix timestamp).
|
|
36
|
-
#
|
|
36
|
+
# remaining is coerced to Integer for header output even when info.remaining is fractional;
|
|
37
|
+
# the RateLimit header spec requires integer values.
|
|
37
38
|
def to_response_headers
|
|
38
39
|
return {} unless matched? && !error? && info
|
|
39
40
|
|
|
40
41
|
{
|
|
41
|
-
"RateLimit-Limit" => info.resolved_limit.to_s,
|
|
42
|
-
"RateLimit-Remaining" => info.remaining.to_s,
|
|
42
|
+
"RateLimit-Limit" => info.resolved_limit.to_i.to_s,
|
|
43
|
+
"RateLimit-Remaining" => info.remaining.to_i.to_s,
|
|
43
44
|
"RateLimit-Reset" => info.reset_at.to_i.to_s
|
|
44
45
|
}
|
|
45
46
|
end
|
|
@@ -48,8 +49,12 @@ module Labkit
|
|
|
48
49
|
# Per-window counter data attached to a matched Result.
|
|
49
50
|
# resolved_limit - the evaluated limit Integer for this rule
|
|
50
51
|
# resolved_period - the evaluated period Integer (seconds) for this rule
|
|
51
|
-
# count - the
|
|
52
|
-
#
|
|
52
|
+
# count - the post-increment counter value as a Float; integer-valued
|
|
53
|
+
# for default cost=1 callers, fractional for cost-mode callers.
|
|
54
|
+
# Pre-2.x releases exposed this as Integer; see the migration
|
|
55
|
+
# note in the cost-aware Lua script change.
|
|
56
|
+
# remaining - requests remaining before the limit is hit (floors at 0).
|
|
57
|
+
# Inherits Float typing from count when count is fractional.
|
|
53
58
|
# reset_at - best-effort UTC Time when the counter window resets
|
|
54
59
|
Result::Info = Data.define(:resolved_limit, :resolved_period, :count, :remaining, :reset_at)
|
|
55
60
|
end
|
data/lib/labkit/rate_limit.rb
CHANGED
|
@@ -43,9 +43,10 @@ module Labkit
|
|
|
43
43
|
# @param rules [Array<Rule>] ordered list of rules (first match wins)
|
|
44
44
|
# @param redis [Object, nil] Redis client; falls back to config.redis
|
|
45
45
|
# @param logger [Logger, nil] logger; falls back to config.logger
|
|
46
|
+
# @param cost [Numeric] amount to add to the counter; see Limiter#check
|
|
46
47
|
# @return [Result]
|
|
47
|
-
def check(name:, identifier:, rules:, redis: nil, logger: nil)
|
|
48
|
-
Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier)
|
|
48
|
+
def check(name:, identifier:, rules:, redis: nil, logger: nil, cost: 1)
|
|
49
|
+
Limiter.new(name: name, rules: rules, redis: redis, logger: logger).check(identifier, cost: cost)
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "redis"
|
|
5
|
+
|
|
6
|
+
module Labkit
|
|
7
|
+
module Redis
|
|
8
|
+
# Wraps a Lua script for EVALSHA-with-NOSCRIPT-fallback execution.
|
|
9
|
+
# The SHA is computed once at construction. Redis caches the script
|
|
10
|
+
# body the first time EVAL is invoked; subsequent EVALSHA calls hit
|
|
11
|
+
# that cache. SCRIPT FLUSH or a Redis restart drops the cache; the
|
|
12
|
+
# NOSCRIPT recovery re-ships the body and re-populates it.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
16
|
+
# return redis.call('INCRBY', KEYS[1], ARGV[1])
|
|
17
|
+
# LUA
|
|
18
|
+
#
|
|
19
|
+
# pool.with { |conn| SCRIPT.eval(conn, keys: ["counter"], argv: [1]) }
|
|
20
|
+
class Script
|
|
21
|
+
attr_reader :body, :sha
|
|
22
|
+
|
|
23
|
+
def initialize(body)
|
|
24
|
+
@body = body.freeze
|
|
25
|
+
# SHA1 is mandated by the Redis EVALSHA wire protocol, not a discretionary hash choice.
|
|
26
|
+
@sha = OpenSSL::Digest::SHA1.hexdigest(body).freeze # rubocop:disable Fips/SHA1
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param conn a Redis client (the connection checked out of a pool)
|
|
31
|
+
# @param keys [Array] KEYS arguments to the Lua script
|
|
32
|
+
# @param argv [Array] ARGV arguments to the Lua script
|
|
33
|
+
# @return the script's return value
|
|
34
|
+
def eval(conn, keys:, argv:)
|
|
35
|
+
conn.evalsha(@sha, keys: keys, argv: argv)
|
|
36
|
+
rescue ::Redis::CommandError => e
|
|
37
|
+
raise unless e.message.start_with?("NOSCRIPT")
|
|
38
|
+
|
|
39
|
+
conn.eval(@body, keys: keys, argv: argv)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/labkit/redis.rb
ADDED
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: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Newdigate
|
|
@@ -609,6 +609,8 @@ files:
|
|
|
609
609
|
- lib/labkit/rate_limit/metrics.rb
|
|
610
610
|
- lib/labkit/rate_limit/result.rb
|
|
611
611
|
- lib/labkit/rate_limit/rule.rb
|
|
612
|
+
- lib/labkit/redis.rb
|
|
613
|
+
- lib/labkit/redis/script.rb
|
|
612
614
|
- lib/labkit/rspec/README.md
|
|
613
615
|
- lib/labkit/rspec/matchers.rb
|
|
614
616
|
- lib/labkit/rspec/matchers/user_experience_matchers.rb
|