gitlab-labkit 5.8.0 → 5.9.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/AGENTS.md +19 -41
- data/CODEOWNERS +1 -1
- data/lib/labkit/rate_limit/README.md +73 -13
- data/lib/labkit/rate_limit/evaluator.rb +83 -13
- data/lib/labkit/rate_limit/limiter.rb +6 -2
- data/lib/labkit/rate_limit/result.rb +6 -3
- data/lib/labkit/rate_limit/rule.rb +31 -5
- metadata +1 -2
- data/.gitlab/CODEOWNERS +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23080f09f7295046f43c9519b76d3a71bef133c8178f60fb9d33bc13311c0947
|
|
4
|
+
data.tar.gz: 35ca7c5427293cad362e700551c1a6a33ba439866f8acd60e065944ab8085b6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e83c05dc9f364acadcf7fa4c5343441476e8c4ab1fdb7b1ed36122499649caef0f6a6607d01e0c511042eb3f940fe4e799e0b422749ed28b8f8857ddc5c5fc6f
|
|
7
|
+
data.tar.gz: 0d9a4dccbb22b559ba33e2403151b4284b4e8240970384757ab1ec455d478bf8ad9865f7573883b7c11a819f68718c8111781e39856f6b4ad28f99dbf117e0a7
|
data/AGENTS.md
CHANGED
|
@@ -122,48 +122,26 @@ Keep an MR to one concern, and separate mechanical changes such as renames from
|
|
|
122
122
|
|
|
123
123
|
## Rate limiting
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
That makes the Redis key shape and the Lua scripts as much a contract as the HTTP headers, so changes here follow stricter rules than the rest of the gem.
|
|
128
|
-
|
|
129
|
-
### Where each SDK keeps it
|
|
130
|
-
|
|
131
|
-
| SDK | Repository | Module | Tests | Status |
|
|
132
|
-
| --- | --- | --- | --- | --- |
|
|
133
|
-
| Ruby (this repository) | gitlab-org/ruby/gems/labkit-ruby | `lib/labkit/rate_limit/` | `spec/labkit/rate_limit/` | released |
|
|
134
|
-
| Go | gitlab-org/labkit | `v2/ratelimit/` | `v2/ratelimit/*_test.go`, run with `go -C v2 test ./ratelimit/...` | released |
|
|
135
|
-
| Rust | gitlab-org/rust/labkit-rs | in progress | | not released, skip |
|
|
136
|
-
| Python | gitlab-org/labkit-python | in progress | | not released, skip |
|
|
137
|
-
| Spec | gitlab-org/quality/tooling/labkit-spec | `conformance/README.md` ("v1 decisions"), `conformance/ratelimit_scenarios/*.json`, `conformance/aspect_ratelimit.go` | `conformance/` | judge |
|
|
125
|
+
Services in every language enforce the same rate limits against one shared Redis.
|
|
126
|
+
So these are a contract between all SDKs:
|
|
138
127
|
|
|
139
|
-
|
|
128
|
+
- Redis keys, values, and TTLs
|
|
129
|
+
- Lua scripts
|
|
130
|
+
- the counting rule that decides a block
|
|
131
|
+
- the response status and `RateLimit-*` headers
|
|
132
|
+
- metrics and log fields
|
|
140
133
|
|
|
141
|
-
|
|
142
|
-
This section defines the layers; the current values (key grammar, header names, counting rules, metric names, log fields) live in labkit-spec, in the "v1 decisions" list in `conformance/README.md` and the scenarios under `conformance/ratelimit_scenarios/`.
|
|
134
|
+
[labkit-spec](https://gitlab.com/gitlab-org/quality/tooling/labkit-spec) owns that contract: the "v1 decisions" in `conformance/README.md` and the scenarios in `conformance/ratelimit_scenarios/`.
|
|
143
135
|
If this file and labkit-spec disagree, labkit-spec wins.
|
|
136
|
+
`Labkit::RateLimit` is documented in `lib/labkit/rate_limit/README.md`.
|
|
137
|
+
|
|
138
|
+
| SDK | Repository | Rate limiter | Tests |
|
|
139
|
+
| --- | --- | --- | --- |
|
|
140
|
+
| Ruby | [labkit-ruby](https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby) | `lib/labkit/rate_limit/` | `bundle exec rspec spec/labkit/rate_limit` |
|
|
141
|
+
| Go | [labkit](https://gitlab.com/gitlab-org/labkit) | `v2/ratelimit/` | `go -C v2 test ./ratelimit/...` |
|
|
142
|
+
| Rust | [labkit-rs](https://gitlab.com/gitlab-org/rust/labkit-rs) | `labkit/src/ratelimit/` | `cargo test -p labkit --features ratelimit-redis --lib ratelimit` |
|
|
144
143
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
It changes in labkit-spec first, then every SDK follows.
|
|
150
|
-
2. **Observable contract.**
|
|
151
|
-
Anything a caller or an operator can see: response status and headers, the counting rule that decides a block, log line messages and field sets, metric families and labels.
|
|
152
|
-
Also changes in labkit-spec first.
|
|
153
|
-
3. **Behaviour within the contract.**
|
|
154
|
-
Input validation and edge cases the contract already implies, where one SDK does something the contract does not allow.
|
|
155
|
-
These may be fixed SDK by SDK, and every SDK should end up the same.
|
|
156
|
-
|
|
157
|
-
A change that mixes layers is governed by the strictest one: storage, then observable, then behaviour.
|
|
158
|
-
When a change does not fit one layer clearly, say so in the MR instead of picking one.
|
|
159
|
-
|
|
160
|
-
### Rules for rate-limit changes
|
|
161
|
-
|
|
162
|
-
- A change to layer 1 or 2 opens a labkit-spec MR first and waits for it to merge.
|
|
163
|
-
- A change to layer 3 should be checked against the Go SDK.
|
|
164
|
-
If Go has the same gap, say so in the MR so it can be ported.
|
|
165
|
-
- When a review finds a divergence between SDKs, a conformance scenario in labkit-spec must follow in the same week, so the harness sees the gap next time.
|
|
166
|
-
The author of the MR where the divergence was found owns this.
|
|
167
|
-
Mention the sync agent on the review thread to have it draft the scenario MR; if it cannot, open a labkit-spec issue and link it from the thread.
|
|
168
|
-
- Never edit Lua scripts, key construction, header names, metric names, or log field names to fix a Ruby-only bug.
|
|
169
|
-
If a fix seems to need that, it is a contract change.
|
|
144
|
+
- Change the contract in labkit-spec first, then in every SDK. Merge the labkit-spec MR before any SDK MR. Never change the contract in one SDK only.
|
|
145
|
+
- A fix inside the contract can land in one SDK. Check the other SDKs, and say in the MR if they have the same gap.
|
|
146
|
+
- If you are not sure that a change is inside the contract, treat it as a contract change and say so in the MR.
|
|
147
|
+
- When review finds a gap between SDKs, add a labkit-spec scenario for it in the same week, or open a labkit-spec issue and link it from the thread. The author of the MR where the gap was found owns this.
|
data/CODEOWNERS
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# CODEOWNERS is used to lookup assignees for
|
|
2
2
|
# Renovate Bot dependency change Merge Requests.
|
|
3
3
|
# https://docs.renovatebot.com/configuration-options/#assigneesfromcodeowners
|
|
4
|
-
* @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
|
|
4
|
+
* @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2 @lhollinda
|
|
@@ -177,6 +177,7 @@ A `Rule` is a `Data.define` value object with the following fields:
|
|
|
177
177
|
| `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
|
|
178
178
|
| `count_distinct` | Optional identifier key. When set, the rule counts distinct values seen for that key within the window, backed by a Redis SET rather than a counter. Must not overlap `characteristics`. |
|
|
179
179
|
| `ban_for` | Optional ban duration, whole seconds, minimum 1. Changes the accounting; `action` still decides who is blocked. Rejected on `:skip`. May be a callable resolved on every check. |
|
|
180
|
+
| `admit_counted_values` | Optional, default `false`. Only valid with `count_distinct`, and rejected on `:skip`. When `true`, the rule counts only the values it admits, so values already counted keep passing once the set is full. Rename the rule when changing it. See [Admitting counted values](#admitting-counted-values). |
|
|
180
181
|
|
|
181
182
|
Making `limit` or `period` callable is the supported pattern for
|
|
182
183
|
runtime-tunable thresholds (e.g. feature flags or database-backed settings):
|
|
@@ -193,9 +194,11 @@ Labkit::RateLimit::Rule.new(
|
|
|
193
194
|
`Rule.new` raises `ArgumentError` for a static `limit` under 1 or a static
|
|
194
195
|
`period` under one second, except on `:skip` rules where both are inert.
|
|
195
196
|
Callables are not validated: a `limit` resolving to `0` blocks every matching
|
|
196
|
-
request, which is how to express "deny all" dynamically
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
request, which is how to express "deny all" dynamically. On an
|
|
198
|
+
`admit_counted_values` rule it only denies new values, since values already
|
|
199
|
+
counted in the window still pass; to block those too, put a counter rule whose
|
|
200
|
+
limit resolves to `0` in front of it. A `period` resolving under one second
|
|
201
|
+
reaches `EXPIRE` as `0` and deletes the counter on every write.
|
|
199
202
|
|
|
200
203
|
### Matchers
|
|
201
204
|
|
|
@@ -315,7 +318,7 @@ flowchart TD
|
|
|
315
318
|
Eval --> Build[Build Evaluation<br/>resolve limit/period]
|
|
316
319
|
Build --> Add[Add evaluation to Result]
|
|
317
320
|
Add --> Emit[Emit rule_evaluations_total<br/>+ limit/period gauges]
|
|
318
|
-
Emit --> Act{"result.block?<br/>(:limit rule over its limit,<br/>
|
|
321
|
+
Emit --> Act{"result.block?<br/>(:limit rule over its limit,<br/>its ban in force, or an admitting<br/>rule that refused the value)"}
|
|
319
322
|
Act -->|yes| Return([Return Result<br/>action=:block])
|
|
320
323
|
Act -->|"no (:log, or under limit)"| Iter
|
|
321
324
|
Iter -->|no more rules| Return2([Return Result reporting<br/>most-constraining evaluation,<br/>or matched=false if none])
|
|
@@ -349,10 +352,11 @@ visible via `checks_total{error="true"}` and the
|
|
|
349
352
|
|
|
350
353
|
The rule's `action` describes what the rule does; the result's `action`
|
|
351
354
|
describes the outcome — what the caller should do — and is only ever `:allow`
|
|
352
|
-
or `:block`. A matching rule increments its counter, with
|
|
353
|
-
`:skip` rules never touch Redis,
|
|
354
|
-
already in force short-circuits before incrementing
|
|
355
|
-
`
|
|
355
|
+
or `:block`. A matching rule increments its counter, with three exceptions:
|
|
356
|
+
`:skip` rules never touch Redis, a rule carrying `ban_for` whose ban is
|
|
357
|
+
already in force short-circuits before incrementing, and an
|
|
358
|
+
`admit_counted_values` rule adds a value only when it admits it. The `result`
|
|
359
|
+
label on `rule_evaluations_total` records what each evaluation decided:
|
|
356
360
|
|
|
357
361
|
| rule action | what it does | exceeded? | result action | `result` label | terminating? |
|
|
358
362
|
|-------------|-----------------------------------------------|-----------|---------------|----------------|---------------|
|
|
@@ -419,6 +423,60 @@ window, and it stops counting while that ban holds:
|
|
|
419
423
|
suit counting discrete failures, such as bad credentials, better than they
|
|
420
424
|
suit variable-cost resource limits.
|
|
421
425
|
|
|
426
|
+
### Admitting counted values
|
|
427
|
+
|
|
428
|
+
A plain `count_distinct` rule adds every checked value to its set, including
|
|
429
|
+
the values it blocks. Once one value is blocked, the count stays over the
|
|
430
|
+
limit for the rest of the window, so values that were counted earlier are
|
|
431
|
+
blocked too. That suits "block after N distinct values", such as the number
|
|
432
|
+
of users seen from one IP.
|
|
433
|
+
|
|
434
|
+
Set `admit_counted_values: true` to cap how many distinct values can be
|
|
435
|
+
admitted per window instead:
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
Labkit::RateLimit::Rule.new(
|
|
439
|
+
name: "participants_per_namespace",
|
|
440
|
+
limit: 20,
|
|
441
|
+
period: 86_400,
|
|
442
|
+
characteristics: [:namespace],
|
|
443
|
+
count_distinct: :email_digest,
|
|
444
|
+
admit_counted_values: true
|
|
445
|
+
)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
- A value already in the set is allowed and leaves the count unchanged, even
|
|
449
|
+
when the set is full.
|
|
450
|
+
- A new value is added only while the count is below the limit. Otherwise the
|
|
451
|
+
request is blocked and the value is not added, so a blocked value stays
|
|
452
|
+
blocked when it is retried and the count does not grow past the limit.
|
|
453
|
+
- A blocked request reports the set's count and `RateLimit-Remaining: 0`. The
|
|
454
|
+
block comes from the admission, not from `count > limit`, the same way a ban
|
|
455
|
+
decides the block apart from the count.
|
|
456
|
+
- If the limit is lowered during a window, the set can hold more values than
|
|
457
|
+
the new limit. Values already in it are still allowed, and new values are
|
|
458
|
+
blocked until the window ends.
|
|
459
|
+
- `peek` reports what `check` would decide when the identifier has the
|
|
460
|
+
`count_distinct` value, without writing: exceeded only when that value is
|
|
461
|
+
not counted and the count has reached the limit. Without the value, `check`
|
|
462
|
+
skips the rule, but `peek` answers for a value that is not counted yet, so
|
|
463
|
+
it reports exceeded once the count reaches the limit.
|
|
464
|
+
- A `:log` rule does the same accounting, including not adding refused
|
|
465
|
+
values, and only skips the blocking.
|
|
466
|
+
- `cost:` is ignored, as for a plain `count_distinct` rule, so a `cost: 0`
|
|
467
|
+
check still admits a new value.
|
|
468
|
+
- Rules count a request up to the one that blocks it. An admitting rule placed
|
|
469
|
+
before a counter rule that blocks still admits the value, which then takes a
|
|
470
|
+
slot for the rest of the window. Put admitting rules after the counter rules
|
|
471
|
+
in the same limiter.
|
|
472
|
+
|
|
473
|
+
The Redis key and the stored values are the same as for a plain
|
|
474
|
+
`count_distinct` rule, and a plain rule adds every value it checks, including
|
|
475
|
+
the ones it blocks. A rule switched between the two modes keeps those values,
|
|
476
|
+
and they count as admitted until the window ends. Rename the rule when
|
|
477
|
+
changing its mode, which abandons its counters (see [Rule](#rule)), and give a
|
|
478
|
+
rule the same mode in every SDK that writes to it.
|
|
479
|
+
|
|
422
480
|
### Redis keys
|
|
423
481
|
|
|
424
482
|
Each matched check writes a key shaped:
|
|
@@ -439,7 +497,8 @@ is not extended on subsequent increments, so the window is a true fixed
|
|
|
439
497
|
window starting at the first request, not a sliding window.
|
|
440
498
|
|
|
441
499
|
A check is a single `EVALSHA` of `INCR_SCRIPT` (or `SADD_SCRIPT` for
|
|
442
|
-
`count_distinct` rules,
|
|
500
|
+
`count_distinct` rules, `ADMIT_SCRIPT` for `count_distinct` rules with
|
|
501
|
+
`admit_counted_values`, or `BAN_SCRIPT` for rules carrying `ban_for`). Doing the whole
|
|
443
502
|
read-modify-write inside Lua means there is no window between the increment
|
|
444
503
|
and the `EXPIRE` in which a key could be left without a TTL.
|
|
445
504
|
|
|
@@ -488,9 +547,10 @@ key with no expiry, the TTL only needs reading once — after the increment.
|
|
|
488
547
|
That single read distinguishes both cases the script must handle, so there is
|
|
489
548
|
no reason to read it again before mutating.
|
|
490
549
|
|
|
491
|
-
`peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL
|
|
492
|
-
|
|
493
|
-
|
|
550
|
+
`peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL`,
|
|
551
|
+
plus `SISMEMBER` for an `admit_counted_values` rule when the identifier has the
|
|
552
|
+
value) and never issues `EXPIRE`, so it cannot start or extend a window. A
|
|
553
|
+
missing key (`GET → nil`, `TTL → -2`) is reported as `count = 0`, and `build_evaluation`
|
|
494
554
|
falls back to the rule's period for `reset_at` since there is no Redis-side
|
|
495
555
|
window to read.
|
|
496
556
|
|
|
@@ -503,7 +563,7 @@ the most constraining one (see [Evaluation flow](#evaluation-flow)):
|
|
|
503
563
|
```ruby
|
|
504
564
|
result.matched? # => true if some rule matched
|
|
505
565
|
result.skipped? # => true if a matched :skip rule bypassed the check
|
|
506
|
-
result.exceeded? # => true if the reported rule
|
|
566
|
+
result.exceeded? # => true if the reported rule is over its limit
|
|
507
567
|
result.action # => :block | :allow — what the caller should do
|
|
508
568
|
result.block? # => result.action == :block
|
|
509
569
|
result.rule # => the reported Rule, or nil
|
|
@@ -67,6 +67,40 @@ module Labkit
|
|
|
67
67
|
return {count, ttl_after}
|
|
68
68
|
LUA
|
|
69
69
|
|
|
70
|
+
# Atomic admission for count_distinct rules carrying admit_counted_values.
|
|
71
|
+
# A member already in the set is admitted without changing it; a new one
|
|
72
|
+
# is added only while SCARD is below the limit. A refused member is never
|
|
73
|
+
# added, so it stays refused on retry and the count does not grow past the
|
|
74
|
+
# limit. Returns post-state {count, TTL, admitted}.
|
|
75
|
+
#
|
|
76
|
+
# The same self-healing as SADD_SCRIPT applies to keys left without TTL.
|
|
77
|
+
# EXPIRE on a missing key does nothing, so a refusal on a missing key (a
|
|
78
|
+
# limit resolving to 0) writes nothing.
|
|
79
|
+
ADMIT_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
|
|
80
|
+
local ttl = ARGV[1]
|
|
81
|
+
local member = ARGV[2]
|
|
82
|
+
local limit = tonumber(ARGV[3])
|
|
83
|
+
|
|
84
|
+
local count = redis.call('SCARD', KEYS[1])
|
|
85
|
+
local admitted = 1
|
|
86
|
+
if redis.call('SISMEMBER', KEYS[1], member) == 0 then
|
|
87
|
+
if count < limit then
|
|
88
|
+
redis.call('SADD', KEYS[1], member)
|
|
89
|
+
count = count + 1
|
|
90
|
+
else
|
|
91
|
+
admitted = 0
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
local ttl_after = redis.call('TTL', KEYS[1])
|
|
96
|
+
if ttl_after < 0 then
|
|
97
|
+
redis.call('EXPIRE', KEYS[1], ttl)
|
|
98
|
+
ttl_after = tonumber(ttl)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return {count, ttl_after, admitted}
|
|
102
|
+
LUA
|
|
103
|
+
|
|
70
104
|
# Atomic ban check + INCR + conditional ban write, for rules carrying
|
|
71
105
|
# ban_for.
|
|
72
106
|
#
|
|
@@ -262,9 +296,10 @@ module Labkit
|
|
|
262
296
|
# here too: check can report a :log rule's evaluation, so excluding them
|
|
263
297
|
# would make peek answer a different question than check.
|
|
264
298
|
#
|
|
265
|
-
# peek does not
|
|
266
|
-
# the rule-keyed compound key,
|
|
267
|
-
#
|
|
299
|
+
# peek does not require the count_distinct identifier key: it reads SCARD
|
|
300
|
+
# on the rule-keyed compound key, so missing-key fail-open does not apply.
|
|
301
|
+
# An admitting rule also looks the value up when it is present (see
|
|
302
|
+
# peek_admission_rule).
|
|
268
303
|
def peek_rules(identifier, rule_context, cursor)
|
|
269
304
|
result = Result.new
|
|
270
305
|
|
|
@@ -302,6 +337,8 @@ module Labkit
|
|
|
302
337
|
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
303
338
|
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
304
339
|
|
|
340
|
+
return evaluate_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period) if rule.admit_counted_values
|
|
341
|
+
|
|
305
342
|
# cost is ignored for count_distinct rules: SADD is binary (a member is
|
|
306
343
|
# either added or not), and the post-add count is SCARD regardless.
|
|
307
344
|
count, ttl =
|
|
@@ -314,6 +351,15 @@ module Labkit
|
|
|
314
351
|
build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
|
|
315
352
|
end
|
|
316
353
|
|
|
354
|
+
# The count does not grow past the limit in this mode, so the script's
|
|
355
|
+
# admission decides the block, the way a ban decides it apart from the count.
|
|
356
|
+
# cost is ignored, as for a plain count_distinct rule.
|
|
357
|
+
def evaluate_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period)
|
|
358
|
+
count, ttl, admitted = admit_with_ttl(redis_key, identifier[rule.count_distinct], resolved_limit, resolved_period)
|
|
359
|
+
|
|
360
|
+
build_evaluation(rule, resolved_limit, resolved_period, count, ttl, exceeded: !admitted)
|
|
361
|
+
end
|
|
362
|
+
|
|
317
363
|
def peek_rule(rule, identifier, rule_context)
|
|
318
364
|
return peek_ban_rule(rule, identifier, rule_context) if rule.ban_for
|
|
319
365
|
|
|
@@ -321,10 +367,22 @@ module Labkit
|
|
|
321
367
|
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
322
368
|
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
323
369
|
|
|
370
|
+
return peek_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period) if rule.admit_counted_values
|
|
371
|
+
|
|
324
372
|
count, ttl = rule.count_distinct ? scard_with_ttl(redis_key) : read_with_ttl(redis_key)
|
|
325
373
|
build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
|
|
326
374
|
end
|
|
327
375
|
|
|
376
|
+
# An admission is decided before the script writes anything, so with the
|
|
377
|
+
# value peek reports exactly what check would. Without it, check skips the
|
|
378
|
+
# rule, but peek answers for a value that is not counted yet.
|
|
379
|
+
def peek_admission_rule(rule, redis_key, identifier, resolved_limit, resolved_period)
|
|
380
|
+
value = identifier[rule.count_distinct] unless missing_count_distinct_value?(rule, identifier)
|
|
381
|
+
count, ttl, counted = scard_with_ttl(redis_key, member: value)
|
|
382
|
+
|
|
383
|
+
build_evaluation(rule, resolved_limit, resolved_period, count, ttl, exceeded: !counted && count >= resolved_limit)
|
|
384
|
+
end
|
|
385
|
+
|
|
328
386
|
def evaluate_ban_rule(rule, identifier, cost, rule_context)
|
|
329
387
|
resolved_limit = Integer(resolve_value(rule.limit, rule_context))
|
|
330
388
|
resolved_period = Integer(resolve_value(rule.period, rule_context))
|
|
@@ -382,7 +440,7 @@ module Labkit
|
|
|
382
440
|
Result::Evaluation.new(rule: rule, exceeded: banned, info: info)
|
|
383
441
|
end
|
|
384
442
|
|
|
385
|
-
def build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
|
|
443
|
+
def build_evaluation(rule, resolved_limit, resolved_period, count, ttl, exceeded: count > resolved_limit)
|
|
386
444
|
info = Result::Info.new(
|
|
387
445
|
resolved_limit: resolved_limit, resolved_period: resolved_period,
|
|
388
446
|
count: count,
|
|
@@ -390,12 +448,13 @@ module Labkit
|
|
|
390
448
|
reset_at: Time.now.utc + (ttl >= 0 ? ttl : resolved_period)
|
|
391
449
|
)
|
|
392
450
|
|
|
393
|
-
Result::Evaluation.new(rule: rule, exceeded:
|
|
451
|
+
Result::Evaluation.new(rule: rule, exceeded: exceeded, info: info)
|
|
394
452
|
end
|
|
395
453
|
|
|
396
454
|
# RateLimit-Remaining must not exceed RateLimit-Limit, and a counter can
|
|
397
455
|
# sit below zero if an older client credited it. A limit resolving to a
|
|
398
|
-
# non-positive value means "deny all"
|
|
456
|
+
# non-positive value means "deny all" ("deny new values" on an
|
|
457
|
+
# admit_counted_values rule), so the ceiling floors at zero too.
|
|
399
458
|
def remaining_of(resolved_limit, count)
|
|
400
459
|
ceiling = [resolved_limit, 0].max
|
|
401
460
|
(ceiling - count).clamp(0, ceiling)
|
|
@@ -523,17 +582,28 @@ module Labkit
|
|
|
523
582
|
end
|
|
524
583
|
end
|
|
525
584
|
|
|
526
|
-
#
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
585
|
+
# Atomic admission. See ADMIT_SCRIPT.
|
|
586
|
+
def admit_with_ttl(redis_key, member, limit, period)
|
|
587
|
+
member_str = encode_char_value(member.to_s)
|
|
588
|
+
@redis.with do |conn|
|
|
589
|
+
raw_count, ttl, admitted = ADMIT_SCRIPT.eval(conn, keys: [redis_key], argv: [period, member_str, limit])
|
|
590
|
+
[Float(raw_count), Integer(ttl), admitted == 1]
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Pipelined SCARD + TTL, plus SISMEMBER when given a member to look up
|
|
595
|
+
# (admitting rules), returned as the third element. SCARD on a missing key
|
|
596
|
+
# returns 0, so no explicit nil handling is needed (unlike GET in
|
|
597
|
+
# read_with_ttl). SCARD is integer-valued; coerced to Float for type
|
|
598
|
+
# uniformity with the INCR path so callers see a consistent count type.
|
|
599
|
+
def scard_with_ttl(redis_key, member: nil)
|
|
531
600
|
@redis.with do |conn|
|
|
532
|
-
scard, ttl = conn.pipelined do |pipe|
|
|
601
|
+
scard, ttl, counted = conn.pipelined do |pipe|
|
|
533
602
|
pipe.scard(redis_key)
|
|
534
603
|
pipe.ttl(redis_key)
|
|
604
|
+
pipe.sismember(redis_key, encode_char_value(member.to_s)) unless member.nil?
|
|
535
605
|
end
|
|
536
|
-
[Float(scard), ttl]
|
|
606
|
+
[Float(scard), ttl, counted == true]
|
|
537
607
|
end
|
|
538
608
|
end
|
|
539
609
|
|
|
@@ -35,6 +35,8 @@ module Labkit
|
|
|
35
35
|
# @param cost [Numeric] amount to add to the counter. Defaults to 1
|
|
36
36
|
# (count-mode). Pass a non-1 Numeric for cost-mode counters such as
|
|
37
37
|
# resource-usage limits; passing 0 reads the counter without writing.
|
|
38
|
+
# count_distinct rules ignore cost, so a cost of 0 still adds the value
|
|
39
|
+
# (or admits it, on an admit_counted_values rule).
|
|
38
40
|
# A negative cost would credit quota back, so the check fails open and
|
|
39
41
|
# logs instead of counting.
|
|
40
42
|
# @param rule_context [Hash, nil] optional per-request context passed to
|
|
@@ -60,8 +62,10 @@ module Labkit
|
|
|
60
62
|
#
|
|
61
63
|
# When the underlying Redis key does not exist yet, the result reports
|
|
62
64
|
# count=0, exceeded=false, and remaining=resolved_limit; matched? is
|
|
63
|
-
# still true because the rule applied.
|
|
64
|
-
#
|
|
65
|
+
# still true because the rule applied. The exception is an
|
|
66
|
+
# admit_counted_values rule whose limit resolves to 0 or less, which
|
|
67
|
+
# refuses every new value and so reports exceeded. On Redis error the result
|
|
68
|
+
# fails open identically to {#check}.
|
|
65
69
|
#
|
|
66
70
|
# @param identifier [Identifier, Hash] caller attributes for this request
|
|
67
71
|
# @param rule_context [Hash, nil] see {#check}
|
|
@@ -15,7 +15,8 @@ module Labkit
|
|
|
15
15
|
# from the same evaluations are not #==.
|
|
16
16
|
#
|
|
17
17
|
# matched? - true if at least one rule's match conditions were satisfied
|
|
18
|
-
# exceeded? - true if the reported evaluation
|
|
18
|
+
# exceeded? - true if the reported evaluation is over its limit (see
|
|
19
|
+
# Result::Evaluation#exceeded)
|
|
19
20
|
# action - the outcome: what the caller should do
|
|
20
21
|
# :block = a :limit rule matched and is over its limit
|
|
21
22
|
# :allow = everything else, including an exceeded :log rule
|
|
@@ -127,7 +128,8 @@ module Labkit
|
|
|
127
128
|
|
|
128
129
|
# The outcome of counting one matched rule.
|
|
129
130
|
# rule - the evaluated Rule
|
|
130
|
-
# exceeded - whether the
|
|
131
|
+
# exceeded - whether the rule is over its limit: the post-increment count
|
|
132
|
+
# exceeded it, a ban holds, or an admitting rule refused the value
|
|
131
133
|
# info - Result::Info with the per-window counters
|
|
132
134
|
#
|
|
133
135
|
# Evaluations order by constraint: a blocking evaluation (a :limit rule over
|
|
@@ -165,7 +167,8 @@ module Labkit
|
|
|
165
167
|
headers = {}
|
|
166
168
|
headers["RateLimit-Name"] = rule.name
|
|
167
169
|
# Floored at 0 like remaining: a callable limit can resolve below zero
|
|
168
|
-
# to deny all
|
|
170
|
+
# to deny all (deny new values on an admit_counted_values rule), and an
|
|
171
|
+
# older client could credit a counter below zero.
|
|
169
172
|
headers["RateLimit-Limit"] = [info.resolved_limit.to_i, 0].max.to_s
|
|
170
173
|
headers["RateLimit-Observed"] = [info.count.to_i, 0].max.to_s
|
|
171
174
|
headers["RateLimit-Remaining"] = info.remaining.to_i.to_s
|
|
@@ -31,11 +31,22 @@ module Labkit
|
|
|
31
31
|
# the (characteristics-bucketed) period, backed by a Redis SET.
|
|
32
32
|
# When nil (default), the rule counts the number of calls,
|
|
33
33
|
# backed by INCR. The named key must not overlap +characteristics+.
|
|
34
|
+
# admit_counted_values - optional Boolean, only valid with +count_distinct+ and
|
|
35
|
+
# rejected on :skip.
|
|
36
|
+
# When true, the rule counts only the values it admits: a value
|
|
37
|
+
# already in the window's set is allowed even when the set is
|
|
38
|
+
# full, and a new value is added only while the count is below
|
|
39
|
+
# the limit, so a value this rule refuses is never counted.
|
|
40
|
+
# Rules count a request up to the one that blocks it, so put
|
|
41
|
+
# admitting rules after counter rules. Default false; nil
|
|
42
|
+
# means not set.
|
|
34
43
|
#
|
|
35
44
|
# +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
|
|
36
45
|
# characters. It is used as the middle segment of every Redis counter key for
|
|
37
46
|
# this rule, so changing a rule's name mid-window abandons its in-flight counters.
|
|
38
|
-
Rule = Data.define(
|
|
47
|
+
Rule = Data.define(
|
|
48
|
+
:name, :match, :limit, :period, :action, :characteristics, :count_distinct, :ban_for, :admit_counted_values
|
|
49
|
+
) do
|
|
39
50
|
# ban_for changes the accounting; action still decides who gets blocked.
|
|
40
51
|
# A :skip rule counts nothing, so it has no limit to cross and no ban to
|
|
41
52
|
# write.
|
|
@@ -58,7 +69,8 @@ module Labkit
|
|
|
58
69
|
|
|
59
70
|
# A static limit under 1 blocks every request, and a period under a second
|
|
60
71
|
# reaches EXPIRE as 0, deleting the counter on every write. A :skip rule
|
|
61
|
-
# counts nothing, and a callable may still resolve to 0 to mean "deny all"
|
|
72
|
+
# counts nothing, and a callable may still resolve to 0 to mean "deny all"
|
|
73
|
+
# ("deny new values" on an admit_counted_values rule).
|
|
62
74
|
def self.normalize_numeric(field, value, action_sym)
|
|
63
75
|
return value if action_sym == :skip || value.respond_to?(:call)
|
|
64
76
|
return value if value.is_a?(Numeric) && value >= 1
|
|
@@ -66,6 +78,19 @@ module Labkit
|
|
|
66
78
|
raise ArgumentError, "#{field} must be a Numeric of at least 1 or a callable, got #{value.inspect}"
|
|
67
79
|
end
|
|
68
80
|
|
|
81
|
+
# Admission only means something for a SET-backed rule, where a value can
|
|
82
|
+
# already be counted. A counter has no members to admit, and a :skip rule
|
|
83
|
+
# counts nothing.
|
|
84
|
+
def self.normalize_admit_counted_values(value, action_sym, count_distinct_sym)
|
|
85
|
+
# nil means "not set", like count_distinct and ban_for.
|
|
86
|
+
value = false if value.nil?
|
|
87
|
+
raise ArgumentError, "admit_counted_values must be true, false, or nil, got #{value.inspect}" unless [true, false].include?(value)
|
|
88
|
+
raise ArgumentError, "admit_counted_values is not valid on a :skip rule" if value && action_sym == :skip
|
|
89
|
+
raise ArgumentError, "admit_counted_values requires count_distinct" if value && count_distinct_sym.nil?
|
|
90
|
+
|
|
91
|
+
value
|
|
92
|
+
end
|
|
93
|
+
|
|
69
94
|
def self.normalize_count_distinct(value, characteristics_arr)
|
|
70
95
|
sym =
|
|
71
96
|
case value
|
|
@@ -81,9 +106,9 @@ module Labkit
|
|
|
81
106
|
sym
|
|
82
107
|
end
|
|
83
108
|
|
|
84
|
-
def initialize(
|
|
109
|
+
def initialize( # rubocop:disable Metrics/ParameterLists -- one keyword per Rule field; the public API grows by keywords with defaults
|
|
85
110
|
name:, limit:, period:, characteristics:,
|
|
86
|
-
match: {}, action: :limit, count_distinct: nil, ban_for: nil
|
|
111
|
+
match: {}, action: :limit, count_distinct: nil, ban_for: nil, admit_counted_values: false
|
|
87
112
|
)
|
|
88
113
|
raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
|
|
89
114
|
|
|
@@ -109,7 +134,8 @@ module Labkit
|
|
|
109
134
|
action: action_sym,
|
|
110
135
|
characteristics: characteristics_arr,
|
|
111
136
|
count_distinct: count_distinct_sym,
|
|
112
|
-
ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym)
|
|
137
|
+
ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym),
|
|
138
|
+
admit_counted_values: self.class.normalize_admit_counted_values(admit_counted_values, action_sym, count_distinct_sym)
|
|
113
139
|
)
|
|
114
140
|
end
|
|
115
141
|
end
|
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: 5.
|
|
4
|
+
version: 5.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Newdigate
|
|
@@ -570,7 +570,6 @@ files:
|
|
|
570
570
|
- ".gitlab-ci-asdf-versions.yml"
|
|
571
571
|
- ".gitlab-ci-other-versions.yml"
|
|
572
572
|
- ".gitlab-ci.yml"
|
|
573
|
-
- ".gitlab/CODEOWNERS"
|
|
574
573
|
- ".gitlab/duo/agent-config.yml"
|
|
575
574
|
- ".gitlab/merge_request_templates/default.md"
|
|
576
575
|
- ".gitleaks.toml"
|
data/.gitlab/CODEOWNERS
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
* @mkaeppler @ayufan @hmerscher @sankalp_gl @hardikgala @nindurkar @ashs2
|