gitlab-labkit 2.8.1 → 3.0.1
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/labkit/rate_limit/README.md +34 -23
- data/lib/labkit/rate_limit/evaluator.rb +12 -10
- data/lib/labkit/rate_limit/result.rb +3 -3
- data/lib/labkit/rate_limit/rule.rb +2 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fbd752b05c1341ef1292c7a3a1ae3555dd27230d7afc830e0c713547bdd02ee
|
|
4
|
+
data.tar.gz: 6dc8eab0332b9b6851627fe83a95bfb0bacefbe3222d712e28a8e210c19fdb8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56316fcea7b575a62119a814f3e3b4c85dc8d5e93ed72e2316bd47388bd142dd44ae21f07f2b6f40d070573c04bf6b79c26e14601f90e0a2f6fcf263fa05ce36
|
|
7
|
+
data.tar.gz: 65e7e06919637356e25ab5d6f56880df2eb7e340474afeaf01212fe55612439d8bba8707d2d1bc835c864723cd0d15cca135065756ce3f17a9bd959f253775f3
|
|
@@ -15,7 +15,7 @@ flowchart LR
|
|
|
15
15
|
App[Application code] -->|"check(identifier)"| Limiter
|
|
16
16
|
Limiter -->|delegates| Evaluator
|
|
17
17
|
Evaluator -->|iterates ordered| Rules[Rule list]
|
|
18
|
-
Evaluator <-->|
|
|
18
|
+
Evaluator <-->|EVALSHA / GET / TTL| Redis[(Redis)]
|
|
19
19
|
Evaluator -->|emits| Metrics[Prometheus metrics]
|
|
20
20
|
Evaluator -->|returns| Result
|
|
21
21
|
Result --> App
|
|
@@ -146,7 +146,7 @@ A `Rule` is a `Data.define` value object with the following fields:
|
|
|
146
146
|
| `match` | Hash of identifier key/value predicates that must **all** be satisfied for the rule to apply. Empty hash matches anything. See [Matchers](#matchers). |
|
|
147
147
|
| `limit` | Integer request threshold per `period`. May be a callable resolved on every check. |
|
|
148
148
|
| `period` | Window length in seconds. May be a callable resolved on every check. |
|
|
149
|
-
| `action` | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:
|
|
149
|
+
| `action` | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:skip`. Default `:block`. See [Actions](#actions). |
|
|
150
150
|
| `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
|
|
151
151
|
|
|
152
152
|
Making `limit` or `period` callable is the supported pattern for
|
|
@@ -198,7 +198,7 @@ flowchart TD
|
|
|
198
198
|
Build --> Emit[Emit calls_total + limit/period gauges]
|
|
199
199
|
Emit --> Act{rule.action}
|
|
200
200
|
Act -->|":log<br/>(non-terminating)"| Iter
|
|
201
|
-
Act -->|:block
|
|
201
|
+
Act -->|:block| Return([Return Result])
|
|
202
202
|
Iter -->|no more rules| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
|
|
203
203
|
Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
|
|
204
204
|
Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
|
|
@@ -219,17 +219,12 @@ which never touch Redis:
|
|
|
219
219
|
together and the `:log` rule cannot disable the `:block` rule. Note that a
|
|
220
220
|
pure `:log`-only check still emits one `rule="unmatched"` metric entry
|
|
221
221
|
because no terminating rule fired.
|
|
222
|
-
- `:allow` — when exceeded, `Result#action` is `:allow` (rather than
|
|
223
|
-
`:block`). Useful for "always allow this caller even if they're over the
|
|
224
|
-
limit" cases while still observing them via metrics. Evaluation terminates
|
|
225
|
-
on the first match.
|
|
226
222
|
- `:skip` — bypass. A matching rule terminates evaluation with
|
|
227
223
|
`Result#action` `:allow` **without any Redis operation**: nothing is
|
|
228
224
|
counted, so `limit`, `period`, `characteristics`, and `count_distinct` are
|
|
229
225
|
inert and the result carries no `info` (`to_response_headers` is `{}`).
|
|
230
226
|
The match is still observable via `calls_total{action="skip"}`. Use this
|
|
231
|
-
for bypasses
|
|
232
|
-
the bypassed traffic counted.
|
|
227
|
+
for bypasses.
|
|
233
228
|
|
|
234
229
|
### Redis keys
|
|
235
230
|
|
|
@@ -241,9 +236,14 @@ labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
|
|
|
241
236
|
|
|
242
237
|
Characteristic values longer than 200 bytes are replaced with a SHA-256
|
|
243
238
|
hexdigest to bound key length. Missing or empty characteristic values are
|
|
244
|
-
encoded as `_unknown_`. The TTL is set on the first write of each window
|
|
245
|
-
|
|
246
|
-
|
|
239
|
+
encoded as `_unknown_`. The TTL is set on the first write of each window and
|
|
240
|
+
is not extended on subsequent increments, so the window is a true fixed
|
|
241
|
+
window starting at the first request, not a sliding window.
|
|
242
|
+
|
|
243
|
+
A check is a single `EVALSHA` of `INCR_SCRIPT` (or `SADD_SCRIPT` for
|
|
244
|
+
`count_distinct` rules). Doing the whole read-modify-write inside Lua means
|
|
245
|
+
there is no window between the increment and the `EXPIRE` in which a key
|
|
246
|
+
could be left without a TTL.
|
|
247
247
|
|
|
248
248
|
```mermaid
|
|
249
249
|
sequenceDiagram
|
|
@@ -251,24 +251,35 @@ sequenceDiagram
|
|
|
251
251
|
participant E as Evaluator
|
|
252
252
|
participant P as Connection pool
|
|
253
253
|
participant R as Redis
|
|
254
|
+
participant L as INCR_SCRIPT (Lua)
|
|
254
255
|
|
|
255
256
|
E->>P: pool.with { |conn| ... }
|
|
256
257
|
P-->>E: conn
|
|
257
|
-
E->>R:
|
|
258
|
-
R
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
258
|
+
E->>R: EVALSHA INCR_SCRIPT key, [period, cost]
|
|
259
|
+
R->>L: run script
|
|
260
|
+
L->>L: INCRBYFLOAT key cost
|
|
261
|
+
L->>L: TTL key
|
|
262
|
+
alt TTL < 0 (key was missing, or had no expiry)
|
|
263
|
+
L->>L: EXPIRE key period
|
|
264
|
+
Note over L: Returns period as the TTL:<br/>the window starts now.
|
|
265
|
+
else TTL >= 0 (window already running)
|
|
266
|
+
Note over L: TTL is not extended:<br/>fixed window from first write.
|
|
265
267
|
end
|
|
268
|
+
L-->>R: {count, ttl}
|
|
269
|
+
R-->>E: [count, ttl]
|
|
266
270
|
E-->>P: release conn
|
|
267
271
|
```
|
|
268
272
|
|
|
269
|
-
`
|
|
270
|
-
|
|
271
|
-
|
|
273
|
+
Because `INCRBYFLOAT` preserves an existing key's TTL, and creates a missing
|
|
274
|
+
key with no expiry, the TTL only needs reading once — after the increment.
|
|
275
|
+
That single read distinguishes both cases the script must handle, so there is
|
|
276
|
+
no reason to read it again before mutating.
|
|
277
|
+
|
|
278
|
+
`peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL`)
|
|
279
|
+
and never issues `EXPIRE`, so it cannot start or extend a window. A missing
|
|
280
|
+
key (`GET → nil`, `TTL → -2`) is reported as `count = 0`, and `build_result`
|
|
281
|
+
falls back to the rule's period for `reset_at` since there is no Redis-side
|
|
282
|
+
window to read.
|
|
272
283
|
|
|
273
284
|
## Result
|
|
274
285
|
|
|
@@ -22,40 +22,42 @@ module Labkit
|
|
|
22
22
|
# Redis treats the result as a no-op on the stored value while
|
|
23
23
|
# still observing the post-state count and TTL we return.
|
|
24
24
|
#
|
|
25
|
-
#
|
|
25
|
+
# ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry).
|
|
26
26
|
# The -1 case shouldn't arise with the atomic script, but self-healing
|
|
27
27
|
# recovers keys left without TTL by any prior bug.
|
|
28
28
|
INCR_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
29
29
|
local ttl = ARGV[1]
|
|
30
30
|
local cost = tonumber(ARGV[2])
|
|
31
|
-
local ttl_before = redis.call('TTL', KEYS[1])
|
|
32
31
|
|
|
33
32
|
local count = redis.call('INCRBYFLOAT', KEYS[1], cost)
|
|
34
|
-
|
|
33
|
+
local ttl_after = redis.call('TTL', KEYS[1])
|
|
34
|
+
if ttl_after < 0 then
|
|
35
35
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
36
|
+
ttl_after = tonumber(ttl)
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
return {count,
|
|
39
|
+
return {count, ttl_after}
|
|
39
40
|
LUA
|
|
40
41
|
|
|
41
42
|
# Atomic SADD + SCARD + conditional EXPIRE. SET-cardinality counterpart
|
|
42
|
-
# of INCR_SCRIPT; same shape (read TTL,
|
|
43
|
+
# of INCR_SCRIPT; same shape (mutate, read TTL, set TTL when missing,
|
|
43
44
|
# return post-state {count, TTL}). count is SCARD, not the SADD return.
|
|
44
45
|
#
|
|
45
|
-
#
|
|
46
|
+
# ttl_after < 0 covers TTL=-2 (key missing) and TTL=-1 (no expiry),
|
|
46
47
|
# so this also self-heals orphan keys left without TTL.
|
|
47
48
|
SADD_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
48
49
|
local ttl = ARGV[1]
|
|
49
50
|
local member = ARGV[2]
|
|
50
|
-
local ttl_before = redis.call('TTL', KEYS[1])
|
|
51
51
|
|
|
52
52
|
redis.call('SADD', KEYS[1], member)
|
|
53
53
|
local count = redis.call('SCARD', KEYS[1])
|
|
54
|
-
|
|
54
|
+
local ttl_after = redis.call('TTL', KEYS[1])
|
|
55
|
+
if ttl_after < 0 then
|
|
55
56
|
redis.call('EXPIRE', KEYS[1], ttl)
|
|
57
|
+
ttl_after = tonumber(ttl)
|
|
56
58
|
end
|
|
57
59
|
|
|
58
|
-
return {count,
|
|
60
|
+
return {count, ttl_after}
|
|
59
61
|
LUA
|
|
60
62
|
|
|
61
63
|
def initialize(name:, rules:, redis:, logger:)
|
|
@@ -247,7 +249,7 @@ module Labkit
|
|
|
247
249
|
end
|
|
248
250
|
|
|
249
251
|
# Atomically increments the counter by `cost`, sets the TTL on first
|
|
250
|
-
# write, and
|
|
252
|
+
# write, and returns the window's remaining TTL, all in one Redis
|
|
251
253
|
# operation via Lua. See INCR_SCRIPT for the script body.
|
|
252
254
|
#
|
|
253
255
|
# count is parsed as Float because INCRBYFLOAT returns a string-encoded
|
|
@@ -8,9 +8,9 @@ module Labkit
|
|
|
8
8
|
# action - the outcome: what the caller should do
|
|
9
9
|
# :block = rule matched, exceeded, rule configured to block
|
|
10
10
|
# :log = rule matched, exceeded, rule configured to log only
|
|
11
|
-
# :allow = rule matched but count within limit, rule configured
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# :allow = rule matched but count within limit, rule configured
|
|
12
|
+
# to skip (bypass, nothing counted), no rule matched,
|
|
13
|
+
# or error (fail-open)
|
|
14
14
|
# The rule's configured action is available via rule.action.
|
|
15
15
|
# rule - the matched Rule object (nil when matched? is false)
|
|
16
16
|
# error? - true if Redis was unavailable; result fails open (exceeded? is false)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Labkit
|
|
4
4
|
module RateLimit
|
|
5
|
-
KNOWN_ACTIONS = %i[block log
|
|
5
|
+
KNOWN_ACTIONS = %i[block log skip].freeze
|
|
6
6
|
RULE_NAME_PATTERN = /\A[a-z0-9_]+\z/
|
|
7
7
|
RULE_NAME_MAX_LENGTH = 64
|
|
8
8
|
|
|
@@ -13,9 +13,7 @@ module Labkit
|
|
|
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
15
|
# action - :block (enforce), :log (count and log only, do not block,
|
|
16
|
-
# evaluation continues to subsequent rules), :
|
|
17
|
-
# (count but always permit; terminates evaluation on match
|
|
18
|
-
# regardless of whether the limit was exceeded), or :skip
|
|
16
|
+
# evaluation continues to subsequent rules), or :skip
|
|
19
17
|
# (bypass: permit and terminate evaluation on match without
|
|
20
18
|
# counting; performs no Redis operation, so limit, period,
|
|
21
19
|
# characteristics, and count_distinct are inert)
|