gitlab-labkit 4.5.0 → 5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6850fbd8d4f1d8d81e2369bc5d27aa0638857e9cf32d2847a6c93f9efe84d9f
4
- data.tar.gz: ac6b4527b4a38f8917e25ed8576628e498e67e20cd4f0d3dfaf9696447cfd9a3
3
+ metadata.gz: 6d2bc5199fdb961286c36547f3c359b9de03bb8dc8c6c260c2074c177d24fc79
4
+ data.tar.gz: '09d7ddd1b8e64f242ca95c63fce1e7d1a29a8d4d44a3908be2aad99e8df57207'
5
5
  SHA512:
6
- metadata.gz: 80803e406ce6e6da82ed72e252c2e6b59349aae34dc128eb1231688ca071a95ff125d93b882cbdd0ece94897f1ca1c7156cb3abba894421ba54d042e56b961c8
7
- data.tar.gz: 0b8afdf336ae0394ed6dcdea7f597968b3825c13ed01ce1a571a82078532d17e82733aa57a791bf52f26723ad0821be61b785355c25e75479019e605ca71d0de
6
+ metadata.gz: 366832f3f6b6a1f76f2c4638c4303c3ad4c1c9065a876c9bb32a049fb3c94f214804f43ded0f5a30a65b87f3a1c9595e1cf05c85152a1eb9c6d8eed7228a1ddc
7
+ data.tar.gz: 45f429a193fa57913cc61b5fda09356abb2135eb1f596c8d6eb4c0982fe2d4a4012551a5dedd2e2576b27da6017a7632f00cd24991352c15be7716a72abd4957
data/.gitlab/CODEOWNERS CHANGED
@@ -1 +1 @@
1
- * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @sankalp_gl @hardikgala @nindurkar @ashs2
1
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @sankalp_gl @hardikgala @nindurkar @ashs2
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
- * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
4
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
@@ -121,6 +121,31 @@ different question than `check`. A matched `:skip` rule terminates `peek`
121
121
  the same way it terminates `check`: matched, `:allow`, no Redis read, no
122
122
  `info`.
123
123
 
124
+ A peek is counted by its own `peeks_total` counter. See [Metrics](#metrics).
125
+
126
+ ### Clearing state
127
+
128
+ `Limiter#clear(identifier)` deletes this limiter's counters for one
129
+ identifier, along with any ban a rule carrying `ban_for` has written, and returns the
130
+ number of keys removed.
131
+
132
+ ```ruby
133
+ RACK_LIMITER.clear({ ip: "1.2.3.4" }) # => 2
134
+ ```
135
+
136
+ Counters otherwise only disappear when their window expires; this is the only
137
+ way to end one early. It exists for call sites where a later success should
138
+ wipe earlier failures, such as an authentication ban cleared by a valid
139
+ login.
140
+
141
+ Every rule in the limiter is cleared, matched or not, because a caller
142
+ clearing state after a success knows the identifier rather than which rules
143
+ happened to match on the way in. Each rule is deleted in its own call, since
144
+ rules do not share a Redis Cluster slot. Clearing an identifier with no state
145
+ is a no-op returning `0`. A Redis failure fails open like `check`, leaving the
146
+ rest of the state to expire on its own, and the return value counts whatever
147
+ was removed before the failure.
148
+
124
149
  ## Identifier
125
150
 
126
151
  `Identifier` is a small value object wrapping a hash of caller attributes.
@@ -150,6 +175,8 @@ A `Rule` is a `Data.define` value object with the following fields:
150
175
  | `period` | Window length in seconds. May be a callable resolved on every check. |
151
176
  | `action` | What the rule does when it matches. One of `:limit`, `:log`, `:skip`. Default `:limit`. See [Actions](#actions). |
152
177
  | `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
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
+ | `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. |
153
180
 
154
181
  Making `limit` or `period` callable is the supported pattern for
155
182
  runtime-tunable thresholds (e.g. feature flags or database-backed settings):
@@ -229,18 +256,20 @@ flowchart TD
229
256
  Match -->|yes| Skip{rule.action<br/>== :skip?}
230
257
  Skip -->|"yes (no Redis op)"| SkipEmit[Emit rule_evaluations_total<br/>action=skip, result=skip]
231
258
  SkipEmit --> SkipReturn([Return matched=true<br/>action=:allow])
232
- Skip -->|no| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
259
+ Skip -->|no| Ban{"ban_for rule with<br/>a ban in force?"}
260
+ Ban -->|"yes (no increment)"| Build
261
+ Ban -->|no| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
233
262
  Eval --> Build[Build Evaluation<br/>resolve limit/period]
234
263
  Build --> Add[Add evaluation to Result]
235
264
  Add --> Emit[Emit rule_evaluations_total<br/>+ limit/period gauges]
236
- Emit --> Act{"result.block?<br/>(:limit rule over limit)"}
265
+ Emit --> Act{"result.block?<br/>(:limit rule over its limit,<br/>or its ban in force)"}
237
266
  Act -->|yes| Return([Return Result<br/>action=:block])
238
- Act -->|"no (:log, or :limit under limit)"| Iter
267
+ Act -->|"no (:log, or under limit)"| Iter
239
268
  Iter -->|no more rules| Return2([Return Result reporting<br/>most-constraining evaluation,<br/>or matched=false if none])
240
269
  Return --> Check[Emit checks_total<br/>action, matched, error]
241
270
  SkipReturn --> Check
242
271
  Return2 --> Check
243
- Eval -. StandardError .-> Error[Emit errors_total +<br/>checks_total error=true<br/>log warn]
272
+ Eval -. StandardError .-> Error[Emit checks_total error=true<br/>log warn]
244
273
  Error --> ReturnErr([Return error=true<br/>action=:allow])
245
274
  ```
246
275
 
@@ -263,18 +292,13 @@ fail-open also emits nothing here — it was never evaluated; that check is
263
292
  visible via `checks_total{error="true"}` and the
264
293
  `rate_limit_missing_count_distinct` log, which carries the rule name.
265
294
 
266
- During the transition the deprecated `calls_total` counter is additionally
267
- emitted at every point the diagram emits `rule_evaluations_total` (with its
268
- historical per-rule semantics, including the `rule="unmatched"` placeholder
269
- after the loop). It is not shown above to keep the diagram legible; see the
270
- Metrics table below.
271
-
272
295
  ### Actions
273
296
 
274
297
  The rule's `action` describes what the rule does; the result's `action`
275
298
  describes the outcome — what the caller should do — and is only ever `:allow`
276
- or `:block`. The Redis counter is always incremented when a rule matches,
277
- except for `:skip` rules, which never touch Redis. The `result` label on
299
+ or `:block`. A matching rule increments its counter, with two exceptions:
300
+ `:skip` rules never touch Redis, and a rule carrying `ban_for` whose ban is
301
+ already in force short-circuits before incrementing. The `result` label on
278
302
  `rule_evaluations_total` records what each evaluation decided:
279
303
 
280
304
  | rule action | what it does | exceeded? | result action | `result` label | terminating? |
@@ -285,6 +309,15 @@ except for `:skip` rules, which never touch Redis. The `result` label on
285
309
  | `:log` | count against the limit (observability only) | yes | `:allow` | `log` | no — continue |
286
310
  | `:skip` | don't count (bypass) | n/a | `:allow` | `skip` | yes — stop |
287
311
 
312
+ A rule may also carry `ban_for`, which changes the accounting rather than the
313
+ action. On crossing the limit it writes a ban that outlives the counting
314
+ window, and it stops counting while that ban holds:
315
+
316
+ | rule action | with `ban_for` | exceeded? | result action | `result` label | terminating? |
317
+ |-------------|-----------------------------------------------|-----------|---------------|----------------|---------------|
318
+ | `:limit` | count, or skip counting while banned | yes | `:block` | `banned` | yes — stop |
319
+ | `:log` | same accounting, records what it would do | yes | `:allow` | `banned` | no — continue |
320
+
288
321
  - `:limit` — when exceeded, `Result#action` is `:block` and evaluation
289
322
  terminates. Caller should reject the request (e.g. with HTTP 429). When under
290
323
  the limit, evaluation continues to the next rule.
@@ -301,15 +334,51 @@ except for `:skip` rules, which never touch Redis. The `result` label on
301
334
  inert and the result carries no `info` (`to_response_headers` is `{}`).
302
335
  The match is still observable via `rule_evaluations_total{action="skip"}`.
303
336
  Use this for bypasses.
337
+ - `ban_for` — enforcement that outlives the window, on either counting action.
338
+ On crossing the limit the rule writes a second key that keeps blocking for
339
+ `ban_for` seconds, long after the counter window has expired. While that ban
340
+ is in force the rule reports `exceeded? == true` and does not count, so a
341
+ caller cannot extend its own ban, and `reset_at` reports when the ban lifts
342
+ rather than when the window rolls. Use this where repeated failures should
343
+ cost more than a window, such as authentication attempts. State can be
344
+ discarded early with [`#clear`](#clearing-state).
345
+
346
+ With `action: :limit` the ban blocks. With `action: :log` the rule does all
347
+ of the same accounting, including writing the ban and suppressing counting
348
+ while it holds, and only skips the blocking, so a shadow measures what
349
+ enforcement would have produced rather than approximating it.
350
+
351
+ A `ban_for` shorter than `period` is legal but surprising: the ban can lapse
352
+ while the counter is still over the limit, so `peek` reports not-exceeded
353
+ while the next `check` re-bans immediately. Bans are normally longer than the
354
+ window they are counted in.
355
+
356
+ The duration is whole seconds and must be at least 1, since the ban is
357
+ written with `SET EX`. A callable is checked again each time it resolves, and
358
+ a value under a second raises rather than reaching Redis. Being an error, it
359
+ fails open like any other, so a rule whose `ban_for` cannot resolve stops
360
+ blocking entirely: watch `checks_total{error="true"}` after changing one.
361
+
362
+ Take care combining `ban_for` with `cost:`. One expensive call can cross the
363
+ limit on its own, and with a ban attached that costs the caller the whole ban
364
+ rather than a single rejection. Checking with `peek` first has the same edge,
365
+ since the call it deliberately lets through is enough to start a ban. Bans
366
+ suit counting discrete failures, such as bad credentials, better than they
367
+ suit variable-cost resource limits.
304
368
 
305
369
  ### Redis keys
306
370
 
307
371
  Each matched check writes a key shaped:
308
372
 
309
373
  ```
310
- labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
374
+ labkit:rl:{<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]}
311
375
  ```
312
376
 
377
+ The braces are a Redis [hash tag](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags):
378
+ on a Redis Cluster only the braced part decides the slot, so every key for one
379
+ rule and identifier lands on the same shard. `BAN_SCRIPT` touches two keys in
380
+ one call, and a cluster rejects a script whose keys span slots.
381
+
313
382
  Characteristic values longer than 200 bytes are replaced with a SHA-256
314
383
  hexdigest to bound key length. Missing or empty characteristic values are
315
384
  encoded as `_unknown_`. The TTL is set on the first write of each window and
@@ -317,9 +386,24 @@ is not extended on subsequent increments, so the window is a true fixed
317
386
  window starting at the first request, not a sliding window.
318
387
 
319
388
  A check is a single `EVALSHA` of `INCR_SCRIPT` (or `SADD_SCRIPT` for
320
- `count_distinct` rules). Doing the whole read-modify-write inside Lua means
321
- there is no window between the increment and the `EXPIRE` in which a key
322
- could be left without a TTL.
389
+ `count_distinct` rules, or `BAN_SCRIPT` for rules carrying `ban_for`). Doing the whole
390
+ read-modify-write inside Lua means there is no window between the increment
391
+ and the `EXPIRE` in which a key could be left without a TTL.
392
+
393
+ A rule carrying `ban_for` writes a second key alongside its counter, sharing the
394
+ same hash tag and carrying its own `ban_for` TTL:
395
+
396
+ ```
397
+ labkit:rl:{<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]}:ban
398
+ ```
399
+
400
+ The suffix sits outside the tag, so it cannot change the slot and cannot
401
+ collide with a characteristic value, and both keys are found by the same
402
+ `labkit:rl:{<limiter>:<rule>*` glob. The ban key outlives its counter by
403
+ design: that is the whole point of `ban_for`.
404
+ `BAN_SCRIPT` checks the ban before incrementing, so the ban check,
405
+ the increment and the ban write are one atomic operation and a concurrent
406
+ check cannot miss the threshold crossing.
323
407
 
324
408
  ```mermaid
325
409
  sequenceDiagram
@@ -365,11 +449,13 @@ the most constraining one (see [Evaluation flow](#evaluation-flow)):
365
449
 
366
450
  ```ruby
367
451
  result.matched? # => true if some rule matched
452
+ result.skipped? # => true if a matched :skip rule bypassed the check
368
453
  result.exceeded? # => true if the reported rule's counter > limit
369
454
  result.action # => :block | :allow — what the caller should do
370
455
  result.block? # => result.action == :block
371
456
  result.rule # => the reported Rule, or nil
372
457
  result.error? # => true if Redis failed (see Fail-open)
458
+ result.degraded? # => true if a matched count_distinct rule was skipped
373
459
  result.info # => Result::Info or nil
374
460
  result.evaluations # => every counted Result::Evaluation, in rule order
375
461
  result.to_response_headers
@@ -405,12 +491,13 @@ of checks that encountered an error is `checks_total{error="true"}` over
405
491
  `count_distinct` key — that check completes (`action` and `matched` describe
406
492
  its outcome as usual), so `error="true"` is not exclusively fail-open traffic.
407
493
 
408
- The deprecated `gitlab_labkit_rate_limiter_errors_total` counter is still
409
- incremented on every fail-open (whole-check and per-rule `count_distinct`),
410
- and remains the only error metric for `peek`, which emits no `checks_total`
411
- (peek must not inflate the per-check counter). The follow-up MR that removes
412
- `errors_total` must first decide where `peek` errors goa dedicated peek
413
- metric, or logs only.
494
+ A failed-open `peek` emits `peeks_total{error="true"}`, so the fraction of
495
+ peeks that encountered an error is `peeks_total{error="true"}` over
496
+ `peeks_total`. A `peek` emits no `checks_total`.
497
+
498
+ `clear` emits no counter at all. A failure there is benign the counters it
499
+ could not delete expire on their own — so it is only logged, with the limiter
500
+ name in the `name` field.
414
501
 
415
502
  Metric emission itself is best-effort: a failure in the metrics stack never
416
503
  alters the verdict or breaks fail-open, and is logged at WARN with
@@ -425,9 +512,8 @@ flooding).
425
512
  | metric | type | labels | meaning |
426
513
  |-----------------------------------------------------|---------|----------------------------------------------|----------------------------------------------------------------------|
427
514
  | `gitlab_labkit_rate_limiter_checks_total` | counter | `rate_limiter`, `action`, `matched`, `error` | Exactly one increment per `check` call, including fail-open. `action` is what the caller should do (`"allow"` or `"block"`); `matched` and `error` are `"true"`/`"false"`. |
428
- | `gitlab_labkit_rate_limiter_rule_evaluations_total` | counter | `rate_limiter`, `rule`, `action`, `result` | One increment per evaluated rule (plus one per matched `:skip` rule). `action` is the configured rule action (`"limit"`, `"log"`, `"skip"`); `result` is what the evaluation decided (`"allow"`, `"block"`, `"log"`, `"skip"` — see the Actions table). |
429
- | `gitlab_labkit_rate_limiter_calls_total` | counter | `rate_limiter`, `rule`, `action` | **Deprecated** superseded by `checks_total` + `rule_evaluations_total`. Historical per-rule counter: one increment per counted rule (plus one per matched `:skip` rule), `rule="unmatched", action="allow"` when no rule matched. Emitted unchanged during the transition. |
430
- | `gitlab_labkit_rate_limiter_errors_total` | counter | `rate_limiter` | **Deprecated** — use `checks_total{error="true"}`. Fail-open events (any `StandardError` in the labkit path); still the only error metric for `peek`. |
515
+ | `gitlab_labkit_rate_limiter_rule_evaluations_total` | counter | `rate_limiter`, `rule`, `action`, `result` | One increment per evaluated rule (plus one per matched `:skip` rule). `action` is the configured rule action (`"limit"`, `"log"`, `"skip"`); `result` is what the evaluation decided (`"allow"`, `"block"`, `"log"`, `"skip"`, `"banned"` — see the Actions table). |
516
+ | `gitlab_labkit_rate_limiter_peeks_total` | counter | `rate_limiter`, `error` | Exactly one increment per `peek` call, including fail-open. `error` is `"true"`/`"false"`. |
431
517
  | `gitlab_labkit_rate_limiter_limit` | gauge | `rate_limiter`, `rule` | Resolved limit at the last check (useful when `limit:` is callable). |
432
518
  | `gitlab_labkit_rate_limiter_period_seconds` | gauge | `rate_limiter`, `rule` | Resolved period at the last check. |
433
519
 
@@ -436,11 +522,6 @@ the request rate through a limiter — no exclusions or dedup needed. A single
436
522
  `check` call emits **one** `checks_total` increment and as many
437
523
  `rule_evaluations_total` increments as rules it evaluated (possibly zero).
438
524
 
439
- **Transition:** the deprecated `calls_total` and `errors_total` counters keep
440
- emitting exactly as before this split, so existing dashboards and alerts stay
441
- correct while consumers migrate to the new counters. Both are removed in a
442
- follow-up major release once nothing consumes them.
443
-
444
525
  ## Dev/test vs production guards
445
526
 
446
527
  `Limiter.new` and `Rule.new` validate names and configuration. In
@@ -9,6 +9,7 @@ module Labkit
9
9
  # @api private
10
10
  class Evaluator
11
11
  REDIS_KEY_PREFIX = "labkit:rl"
12
+ BAN_KEY_SUFFIX = ":ban"
12
13
  CHAR_VALUE_MAX_LENGTH = 200
13
14
  MISSING_VALUE_SENTINEL = "_unknown_"
14
15
 
@@ -66,6 +67,50 @@ module Labkit
66
67
  return {count, ttl_after}
67
68
  LUA
68
69
 
70
+ # Atomic ban check + INCR + conditional ban write, for rules carrying
71
+ # ban_for.
72
+ #
73
+ # An active ban short-circuits before the increment, so a banned caller
74
+ # cannot extend its own window, matching Rack::Attack::Allow2Ban. This
75
+ # applies to :log rules too: a shadow that kept counting through its own
76
+ # ban would report more than enforcement would produce. The
77
+ # counter and the ban must be read and written together or a concurrent
78
+ # check can miss the threshold crossing.
79
+ #
80
+ # Returns {count, counter TTL, ban TTL}, where a ban TTL below zero means
81
+ # not banned.
82
+ BAN_SCRIPT = Labkit::Redis::Script.new(<<~LUA)
83
+ local rule_counter_key = KEYS[1]
84
+ local ban_key = KEYS[2]
85
+
86
+ local ttl = ARGV[1]
87
+ local cost = tonumber(ARGV[2])
88
+ local limit = tonumber(ARGV[3])
89
+ local ban_for = tonumber(ARGV[4])
90
+
91
+ -- TTL answers both questions at once: >= 0 means the ban key exists
92
+ -- and tells us how long is left, so no separate EXISTS is needed.
93
+ local ban_ttl = redis.call('TTL', ban_key)
94
+ if ban_ttl >= 0 then
95
+ local existing = redis.call('GET', rule_counter_key)
96
+ return {existing or '0', redis.call('TTL', rule_counter_key), ban_ttl}
97
+ end
98
+
99
+ local count = redis.call('INCRBYFLOAT', rule_counter_key, cost)
100
+ local ttl_after = redis.call('TTL', rule_counter_key)
101
+ if ttl_after < 0 then
102
+ redis.call('EXPIRE', rule_counter_key, ttl)
103
+ ttl_after = tonumber(ttl)
104
+ end
105
+
106
+ if tonumber(count) > limit then
107
+ redis.call('SET', ban_key, '1', 'EX', ban_for)
108
+ return {count, ttl_after, ban_for}
109
+ end
110
+
111
+ return {count, ttl_after, -1}
112
+ LUA
113
+
69
114
  def initialize(name:, rules:, redis:, logger:)
70
115
  @name = name
71
116
  @rules = rules
@@ -79,7 +124,6 @@ module Labkit
79
124
  rescue StandardError => e
80
125
  # Intentionally broad: fail-open applies to any unexpected error (network,
81
126
  # timeout, OOM) not only Redis protocol errors.
82
- report_error_metrics
83
127
  log_error(e, identifier, cursor.rule)
84
128
  result = Result.error
85
129
  ensure
@@ -93,11 +137,46 @@ module Labkit
93
137
  # extended. A missing Redis key is treated as count=0 (matched, not exceeded).
94
138
  def peek(identifier, rule_context: nil)
95
139
  cursor = RuleCursor.new
96
- peek_rules(identifier, rule_context, cursor)
140
+ result = peek_rules(identifier, rule_context, cursor)
97
141
  rescue StandardError => e
98
- report_error_metrics
99
142
  log_error(e, identifier, cursor.rule)
100
- Result.error
143
+ result = Result.error
144
+ ensure
145
+ report_peek_metrics(result) if result
146
+ end
147
+
148
+ # Deletes this limiter's counters, and any bans, for one identifier.
149
+ #
150
+ # Every rule is cleared, matched or not: a caller clearing state after a
151
+ # success knows the identifier, not which rules happened to match on the
152
+ # way in. Rules whose keys do not exist are a no-op.
153
+ #
154
+ # Fails open like {#check}: an unreachable Redis leaves the state to
155
+ # expire on its own rather than raising into the caller.
156
+ def clear(identifier)
157
+ # Counted as we go, so a connection lost mid-loop still reports the keys
158
+ # that did go rather than claiming nothing was cleared.
159
+ removed = 0
160
+
161
+ key_groups = @rules.filter_map do |rule|
162
+ next if rule.action == :skip
163
+
164
+ [build_redis_key(rule, identifier), build_redis_key(rule, identifier, BAN_KEY_SUFFIX)]
165
+ end
166
+
167
+ return 0 if key_groups.empty?
168
+
169
+ # One DEL per rule. The hash tag holds the rule name, so a rule's own
170
+ # keys share a slot but two rules do not, and Redis Cluster rejects a
171
+ # DEL spanning slots.
172
+ @redis.with do |conn|
173
+ key_groups.each { |group| removed += conn.del(*group) }
174
+ end
175
+
176
+ removed
177
+ rescue StandardError => e
178
+ log_error(e, identifier, nil)
179
+ removed
101
180
  end
102
181
 
103
182
  private
@@ -113,20 +192,24 @@ module Labkit
113
192
  # rejected and later rules cannot change that. Rules declared after it
114
193
  # are neither counted nor evaluated, so a blocked request debits every
115
194
  # rule up to and including the one that blocked, and none after it.
195
+ # A :limit rule carrying ban_for counts as over its limit for as long
196
+ # as its ban holds, so it terminates for the whole ban. The same rule
197
+ # with action :log does identical accounting and terminates nothing,
198
+ # which is what makes it a faithful shadow.
116
199
  #
117
200
  # Every other matched rule - :log, and :limit while under its limit - is
118
201
  # counted and collected into the returned Result, which reports the
119
202
  # most-constraining evaluation (ranking lives in Result::Evaluation#<=>).
120
203
  # cost is therefore debited from every matching rule, not just the first.
121
204
  #
122
- # Metrics: each evaluated rule emits rule_evaluations_total (plus the
123
- # deprecated per-rule calls_total); the per-check checks_total is
124
- # emitted once in #check. Full contract in the README's Metrics section.
205
+ # Metrics: each evaluated rule emits rule_evaluations_total; the per-check
206
+ # checks_total is emitted once in #check. Full contract in the README's
207
+ # Metrics section.
125
208
  #
126
209
  # SET-mode rules (rule.count_distinct set) that match but whose identifier
127
- # is missing the count_distinct key fail open + log + bump errors_total +
128
- # flag the Result, and the loop continues to the next rule (the rule is
129
- # treated as not applicable rather than aborting the whole evaluation).
210
+ # is missing the count_distinct key fail open + log + flag the Result, and
211
+ # the loop continues to the next rule (the rule is treated as not
212
+ # applicable rather than aborting the whole evaluation).
130
213
  #
131
214
  # Error handling stays whole-check (see #check): a raise part-way through
132
215
  # discards the results of the rules already evaluated, even though their
@@ -150,7 +233,6 @@ module Labkit
150
233
 
151
234
  if rule.count_distinct && missing_count_distinct_value?(rule, identifier)
152
235
  log_missing_count_distinct(rule, identifier)
153
- report_error_metrics
154
236
  result.degraded!
155
237
  next
156
238
  end
@@ -164,7 +246,6 @@ module Labkit
164
246
  # The loop is done, so anything raised from here on belongs to no rule.
165
247
  cursor.rule = nil
166
248
 
167
- report_unmatched_metrics unless result.matched?
168
249
  result
169
250
  rescue StandardError => e # binds e for the ensure
170
251
  raise
@@ -212,6 +293,8 @@ module Labkit
212
293
  end
213
294
 
214
295
  def evaluate_rule(rule, identifier, cost, rule_context)
296
+ return evaluate_ban_rule(rule, identifier, cost, rule_context) if rule.ban_for
297
+
215
298
  redis_key = build_redis_key(rule, identifier)
216
299
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
217
300
  resolved_period = Integer(resolve_value(rule.period, rule_context))
@@ -229,6 +312,8 @@ module Labkit
229
312
  end
230
313
 
231
314
  def peek_rule(rule, identifier, rule_context)
315
+ return peek_ban_rule(rule, identifier, rule_context) if rule.ban_for
316
+
232
317
  redis_key = build_redis_key(rule, identifier)
233
318
  resolved_limit = Integer(resolve_value(rule.limit, rule_context))
234
319
  resolved_period = Integer(resolve_value(rule.period, rule_context))
@@ -237,6 +322,63 @@ module Labkit
237
322
  build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
238
323
  end
239
324
 
325
+ def evaluate_ban_rule(rule, identifier, cost, rule_context)
326
+ resolved_limit = Integer(resolve_value(rule.limit, rule_context))
327
+ resolved_period = Integer(resolve_value(rule.period, rule_context))
328
+ resolved_ban_for = resolve_ban_for(rule, rule_context)
329
+
330
+ count, ttl, ban_ttl = ban_incr_with_ttl(
331
+ build_redis_key(rule, identifier),
332
+ build_redis_key(rule, identifier, BAN_KEY_SUFFIX),
333
+ resolved_period, cost, resolved_limit, resolved_ban_for
334
+ )
335
+
336
+ build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
337
+ end
338
+
339
+ # A callable ban_for is only checked at construction, so it can still hand
340
+ # back something Redis would reject on the SET that writes the ban. Failing
341
+ # here names ban_for rather than surfacing a line number from the script.
342
+ def resolve_ban_for(rule, rule_context)
343
+ raw = resolve_value(rule.ban_for, rule_context)
344
+ seconds = Integer(raw, exception: false)
345
+ raise ArgumentError, "ban_for resolved to #{raw.inspect}, need at least 1 second" if seconds.nil? || seconds < 1
346
+
347
+ seconds
348
+ end
349
+
350
+ def peek_ban_rule(rule, identifier, rule_context)
351
+ resolved_limit = Integer(resolve_value(rule.limit, rule_context))
352
+ resolved_period = Integer(resolve_value(rule.period, rule_context))
353
+
354
+ count, ttl, ban_ttl = read_ban_with_ttl(
355
+ build_redis_key(rule, identifier),
356
+ build_redis_key(rule, identifier, BAN_KEY_SUFFIX)
357
+ )
358
+
359
+ build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
360
+ end
361
+
362
+ # A ban blocks whether it was written by this call or an earlier one, so
363
+ # +exceeded+ tracks the ban rather than the count: once the counter window
364
+ # has expired the count can sit below the limit while the ban still holds.
365
+ # reset_at reports when the caller may retry, which is the ban expiry.
366
+ def build_ban_evaluation(rule, resolved_limit, resolved_period, count, ttl, ban_ttl)
367
+ banned = ban_ttl >= 0
368
+ window_remaining = ttl >= 0 ? ttl : resolved_period
369
+
370
+ info = Result::Info.new(
371
+ resolved_limit: resolved_limit, resolved_period: resolved_period,
372
+ count: count,
373
+ # A ban outlives its counter, so count reads 0 once the window has
374
+ # gone. Nothing is remaining while the ban still blocks.
375
+ remaining: banned ? 0 : [resolved_limit - count, 0].max,
376
+ reset_at: Time.now.utc + (banned ? ban_ttl : window_remaining)
377
+ )
378
+
379
+ Result::Evaluation.new(rule: rule, exceeded: banned, info: info)
380
+ end
381
+
240
382
  def build_evaluation(rule, resolved_limit, resolved_period, count, ttl)
241
383
  info = Result::Info.new(
242
384
  resolved_limit: resolved_limit, resolved_period: resolved_period,
@@ -248,12 +390,18 @@ module Labkit
248
390
  Result::Evaluation.new(rule: rule, exceeded: count > resolved_limit, info: info)
249
391
  end
250
392
 
251
- def build_redis_key(rule, identifier)
252
- key = "#{REDIS_KEY_PREFIX}:#{@name}:#{rule.name}"
393
+ # The braces are the cluster hash tag: a counter and its ban must share a
394
+ # slot because BAN_SCRIPT touches both in one call. The rule name stays
395
+ # inside it so one identifier's keys spread across nodes instead of piling
396
+ # onto one. +suffix+ sits outside, so it cannot change the slot.
397
+ def build_redis_key(rule, identifier, suffix = nil)
398
+ key = "#{REDIS_KEY_PREFIX}:{#{@name}:#{rule.name}"
253
399
  rule.characteristics.each do |char|
254
400
  value = resolve_char_value(char, identifier)
255
- key += ":#{char}:#{encode_char_value(value)}"
401
+ key << ":#{char}:#{encode_char_value(value)}"
256
402
  end
403
+ key << "}"
404
+ key << suffix if suffix
257
405
  key
258
406
  end
259
407
 
@@ -311,6 +459,30 @@ module Labkit
311
459
  end
312
460
  end
313
461
 
462
+ # Atomic ban check + increment + conditional ban write. See BAN_SCRIPT.
463
+ # The returned ban TTL is negative when no ban is in force.
464
+ def ban_incr_with_ttl(counter_key, ban_key, period, cost, limit, ban_for)
465
+ @redis.with do |conn|
466
+ raw_count, ttl, ban_ttl = BAN_SCRIPT.eval(
467
+ conn, keys: [counter_key, ban_key], argv: [period, cost, limit, ban_for]
468
+ )
469
+ [Float(raw_count), Integer(ttl), Integer(ban_ttl)]
470
+ end
471
+ end
472
+
473
+ # Pipelined read of both keys for #peek. No EXPIRE and no SET: peeking a
474
+ # banned identifier must not extend either the window or the ban.
475
+ def read_ban_with_ttl(counter_key, ban_key)
476
+ @redis.with do |conn|
477
+ raw_count, ttl, ban_ttl = conn.pipelined do |pipe|
478
+ pipe.get(counter_key)
479
+ pipe.ttl(counter_key)
480
+ pipe.ttl(ban_key)
481
+ end
482
+ [raw_count.nil? ? 0.0 : Float(raw_count), Integer(ttl), Integer(ban_ttl)]
483
+ end
484
+ end
485
+
314
486
  # Pipelined GET + TTL. No EXPIRE: peek must not extend the window.
315
487
  # A missing key (GET => nil, TTL => -2) is reported as count=0; the
316
488
  # build_evaluation fallback then derives reset_at from the rule period
@@ -390,20 +562,19 @@ module Labkit
390
562
  rule_labels.merge(action: evaluation.rule.action.to_s, result: evaluation_result(evaluation)),
391
563
  logger: @logger
392
564
  )
393
- # Deprecated dual emission - remove together with Metrics.calls_total.
394
- Metrics.safe_increment(
395
- :calls_total,
396
- rule_labels.merge(action: (evaluation.exceeded? ? evaluation.rule.action : :allow).to_s),
397
- logger: @logger
398
- )
399
565
  Metrics.safe_set(:limit_gauge, rule_labels, evaluation.info.resolved_limit, logger: @logger)
400
566
  Metrics.safe_set(:period_gauge, rule_labels, evaluation.info.resolved_period, logger: @logger)
401
567
  end
402
568
 
403
569
  # An exceeded :log rule reports "log" rather than the "allow" the caller
404
570
  # sees, so shadow rules over their limit stay visible.
571
+ # A ban is not a distinct action, so without this a blocking ban would be
572
+ # indistinguishable from an ordinary block. On a ban_for rule exceeded?
573
+ # means the ban is in force, whether this call wrote it or an earlier one
574
+ # did, which is exactly what is worth counting separately.
405
575
  def evaluation_result(evaluation)
406
576
  return "allow" unless evaluation.exceeded?
577
+ return "banned" if evaluation.rule.ban_for
407
578
 
408
579
  evaluation.rule.action == :limit ? "block" : "log"
409
580
  end
@@ -414,13 +585,6 @@ module Labkit
414
585
  { rate_limiter: @name, rule: rule.name, action: "skip", result: "skip" },
415
586
  logger: @logger
416
587
  )
417
- # Deprecated dual emission - remove together with Metrics.calls_total.
418
- Metrics.safe_increment(:calls_total, { rate_limiter: @name, rule: rule.name, action: "skip" }, logger: @logger)
419
- end
420
-
421
- # Deprecated dual emission - remove together with Metrics.calls_total.
422
- def report_unmatched_metrics
423
- Metrics.safe_increment(:calls_total, { rate_limiter: @name, rule: "unmatched", action: "allow" }, logger: @logger)
424
588
  end
425
589
 
426
590
  def report_check_metrics(result)
@@ -436,8 +600,12 @@ module Labkit
436
600
  )
437
601
  end
438
602
 
439
- def report_error_metrics
440
- Metrics.safe_increment(:errors_total, { rate_limiter: @name }, logger: @logger)
603
+ def report_peek_metrics(result)
604
+ Metrics.safe_increment(
605
+ :peeks_total,
606
+ { rate_limiter: @name, error: (result.error? || result.degraded?).to_s },
607
+ logger: @logger
608
+ )
441
609
  end
442
610
  end
443
611
  end
@@ -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 = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
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 = identifier.is_a?(Identifier) ? identifier : Identifier.new(identifier)
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)
@@ -52,14 +52,12 @@ module Labkit
52
52
  )
53
53
  end
54
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.
58
- def calls_total
55
+ # Emitted exactly once per #peek call, including calls that fail open.
56
+ def peeks_total
59
57
  Labkit::Metrics::Client.counter(
60
- :gitlab_labkit_rate_limiter_calls_total,
61
- 'Total number of successful rate limit checks',
62
- { rate_limiter: nil, rule: nil, action: nil }
58
+ :gitlab_labkit_rate_limiter_peeks_total,
59
+ 'Total number of rate limit peeks',
60
+ { rate_limiter: nil, error: nil }
63
61
  )
64
62
  end
65
63
 
@@ -73,16 +71,6 @@ module Labkit
73
71
  )
74
72
  end
75
73
 
76
- # Deprecated: superseded by checks_total{error="true"}. Still emitted
77
- # because it remains the only error metric for #peek.
78
- def errors_total
79
- Labkit::Metrics::Client.counter(
80
- :gitlab_labkit_rate_limiter_errors_total,
81
- 'Total number of rate limit check errors',
82
- { rate_limiter: nil }
83
- )
84
- end
85
-
86
74
  def limit_gauge
87
75
  Labkit::Metrics::Client.gauge(
88
76
  :gitlab_labkit_rate_limiter_limit,
@@ -133,9 +133,10 @@ module Labkit
133
133
  # exceeded - whether the post-increment count exceeded the resolved limit
134
134
  # info - Result::Info with the per-window counters
135
135
  #
136
- # Evaluations order by constraint: a blocking evaluation (:limit rule over
137
- # its limit) ranks strictly first - it decides the request no matter what
138
- # any other rule reports - then exceeded ones, then fewest remaining.
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.
139
140
  # remaining floors at 0, so an exceeded :log rule ties with one sitting
140
141
  # exactly on its limit; ranking exceeded ahead keeps a breach from being
141
142
  # hidden by a rule that merely reached its limit.
@@ -14,10 +14,17 @@ module Labkit
14
14
  # period - window in seconds; may be a callable (resolved per check)
15
15
  # action - :limit (enforce; the result blocks when the rule is over
16
16
  # its limit, which also terminates evaluation), :log (count
17
- # and log only, never blocks and never terminates), or
18
- # :skip (bypass: permit and terminate evaluation on match
19
- # without counting; performs no Redis operation, so limit,
20
- # period, characteristics, and count_distinct are inert)
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.
21
28
  # characteristics - identifier keys used to build the compound Redis counter key
22
29
  # count_distinct - optional Symbol naming an identifier key. When set, the rule
23
30
  # counts the number of distinct values seen for that key within
@@ -28,7 +35,27 @@ module Labkit
28
35
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
29
36
  # characters. It is used as the middle segment of every Redis counter key for
30
37
  # this rule, so changing a rule's name mid-window abandons its in-flight counters.
31
- 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
+
32
59
  def self.normalize_count_distinct(value, characteristics_arr)
33
60
  sym =
34
61
  case value
@@ -44,7 +71,10 @@ module Labkit
44
71
  sym
45
72
  end
46
73
 
47
- def initialize(name:, limit:, period:, characteristics:, match: {}, action: :limit, count_distinct: nil)
74
+ def initialize(
75
+ name:, limit:, period:, characteristics:,
76
+ match: {}, action: :limit, count_distinct: nil, ban_for: nil
77
+ )
48
78
  raise ArgumentError, "name must be a String or Symbol, got #{name.class}" unless name.is_a?(String) || name.is_a?(Symbol)
49
79
 
50
80
  name_str = name.to_s
@@ -59,6 +89,7 @@ module Labkit
59
89
  end
60
90
 
61
91
  characteristics_arr = Array(characteristics).map(&:to_sym).freeze
92
+ count_distinct_sym = self.class.normalize_count_distinct(count_distinct, characteristics_arr)
62
93
 
63
94
  super(
64
95
  name: name_str.freeze,
@@ -67,7 +98,8 @@ module Labkit
67
98
  period: period,
68
99
  action: action_sym,
69
100
  characteristics: characteristics_arr,
70
- count_distinct: self.class.normalize_count_distinct(count_distinct, characteristics_arr)
101
+ count_distinct: count_distinct_sym,
102
+ ban_for: self.class.normalize_ban_for(ban_for, action_sym, count_distinct_sym)
71
103
  )
72
104
  end
73
105
  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: 4.5.0
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate