gitlab-labkit 2.8.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ad3bb9ba369010544041c2ab2d2ec2d8610c535c5b2c5e592fe0e8313c56a66
4
- data.tar.gz: 4fa90bdb331f44920cc9969603db7189e42dec21c7fab1d7593e91455de323c3
3
+ metadata.gz: 22a38c0fe1a2f28aa538b80348911947c6de64c16d7d870d24835332796faa99
4
+ data.tar.gz: 6259d696d178f3008aefdf91de71731c81d460cef08259555829f15d8abd462c
5
5
  SHA512:
6
- metadata.gz: 21c914360a54bb30e914e7b6771781657463b3febd83b81634b88513f2986d7d9f25016bae84781b69918429a49c13fdfdf7da8581d385bc13adb698c8728f1d
7
- data.tar.gz: e8b1b9f46c438e2608a1f12e55d2fd3f518586795dfb0327b08fed8bcd6496525fb0fba71c5212fdab45b45a3ca1b3b9c792dc212a838a98124be37d698fd823
6
+ metadata.gz: dd404f717fe9cee20cb544ad23cd5aa3195683ac0efe8bf8363988fdb8dcacc4fcaa817ac6f4cb3fcdb4f2e3d193c6d93cc13deea0344067e44fac7f3c593fb3
7
+ data.tar.gz: cc8d6bb5fe6fde8c17f969d4a708bd41901de5f9383f4821e3acf13a2eb35c240a80d18d3853ac5f29b689d7841df254f540233174646204358e698b9e0f643e
data/.gitlab/CODEOWNERS CHANGED
@@ -1 +1 @@
1
- * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael
1
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @sankalp_gl @hardikgala @nindurkar @ashs2
@@ -1,5 +1,5 @@
1
1
  # DO NOT MANUALLY EDIT; Run ./scripts/update-asdf-version-variables.sh to update this
2
2
  variables:
3
- GL_ASDF_RUBY_VERSION: "4.0.5"
3
+ GL_ASDF_RUBY_VERSION: "4.0.6"
4
4
  GL_ASDF_SHELLCHECK_VERSION: "0.11"
5
5
  GL_ASDF_SHFMT_VERSION: "3.13"
data/.gitlab-ci.yml CHANGED
@@ -19,13 +19,13 @@ include:
19
19
  # It includes standard checks, gitlab-scanners, validations and release processes
20
20
  # common to all projects using this template library.
21
21
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/standard.md
22
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v4.22
22
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/standard-build@v4.36
23
23
 
24
24
  # Runs rspec tests and rubocop on the project
25
25
  # see https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/templates/ruby.md
26
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v4.22
26
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/ruby-build@v4.36
27
27
 
28
- - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v4.22
28
+ - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v4.36
29
29
 
30
30
  # Attach a redis service to the rspec job from common-ci-tasks/ruby-build.
31
31
  # GitLab merges keys when a local job has the same name as an included one,
@@ -25,7 +25,7 @@ repos:
25
25
  # Documentation available at
26
26
  # https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/docs/pre-commit.md
27
27
  - repo: https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks
28
- rev: v4.22 # renovate:managed
28
+ rev: v4.36 # renovate:managed
29
29
 
30
30
  hooks:
31
31
  - id: shellcheck # Run shellcheck for changed Shell files
data/.tool-versions CHANGED
@@ -1,3 +1,3 @@
1
- ruby 4.0.5
1
+ ruby 4.0.6
2
2
  shfmt 3.13
3
3
  shellcheck 0.11
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
4
+ * @reprazent @andrewn @mkaeppler @ayufan @hmerscher @d.barrett @splattael @e_forbes @M_Alvarez @mwoolf @sankalp_gl @hardikgala @nindurkar @ashs2
data/lib/labkit/fields.rb CHANGED
@@ -132,6 +132,12 @@ module Labkit
132
132
  # email reply.
133
133
  GL_SENT_NOTIFICATION_ID = "gl_sent_notification_id"
134
134
 
135
+ # Free-form supplementary details about a log event that do not have a
136
+ # dedicated field. Use sparingly for human-readable context. Avoid logging
137
+ # secrets or PII, and sanitize values that may contain control characters
138
+ # (e.g. newlines) to prevent log injection.
139
+ ADDITIONAL_DETAILS = "additional_details"
140
+
135
141
  # Maps each field constant to its logging field variant version.
136
142
  # A version of 0 means the field is not yet assigned to any variant.
137
143
  VARIANT_VERSION = {
@@ -172,6 +178,7 @@ module Labkit
172
178
  CALLER_ID => 1,
173
179
  ROOT_CALLER_ID => 0,
174
180
  GL_SENT_NOTIFICATION_ID => 0,
181
+ ADDITIONAL_DETAILS => 0,
175
182
  }.freeze
176
183
 
177
184
  # Get the constant name for a field value
@@ -192,7 +199,7 @@ module Labkit
192
199
 
193
200
  MAPPINGS = {
194
201
  Fields::CORRELATION_ID => %w[tags.correlation_id],
195
- Fields::GL_USER_ID => %w[user_id userid extra.user_id extra.current_user_id meta.user_id],
202
+ Fields::GL_USER_ID => %w[user_id userid extra.user_id extra.current_user_id meta.user_id gitlab_user_id],
196
203
  Fields::GL_USER_NAME => %w[username extra.user meta.user],
197
204
  Fields::ERROR_MESSAGE => %w[error err error.message exception.message graphql_errors],
198
205
  Fields::HTTP_STATUS_CODE => %w[status_code extra.status status_text http_status],
@@ -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 <-->|INCR / TTL / EXPIRE| Redis[(Redis)]
18
+ Evaluator <-->|EVALSHA / GET / TTL| Redis[(Redis)]
19
19
  Evaluator -->|emits| Metrics[Prometheus metrics]
20
20
  Evaluator -->|returns| Result
21
21
  Result --> App
@@ -77,8 +77,8 @@ RACK_LIMITER = Labkit::RateLimit::Limiter.new(
77
77
  - `name` must match `/\A[a-z0-9_]+\z/`. It is used as the first segment of
78
78
  every Redis counter key for this limiter, so renaming a `Limiter` abandons
79
79
  any in-flight counters.
80
- - `rules` is an ordered array of `Rule` objects. The first rule whose `match`
81
- hash is satisfied wins (with the exception of `:log` rules — see [Actions](#actions)).
80
+ - `rules` is an ordered array of `Rule` objects. Every rule whose `match` hash
81
+ is satisfied is evaluated and counted (see [Evaluation flow](#evaluation-flow)).
82
82
  - `redis` and `logger` are optional; they fall back to the global
83
83
  `Labkit::RateLimit.config` values.
84
84
 
@@ -99,7 +99,7 @@ result = RACK_LIMITER.check(
99
99
  endpoint: request.path
100
100
  )
101
101
 
102
- if result.exceeded? && result.action == :block
102
+ if result.action == :block
103
103
  response.headers.merge!(result.to_response_headers)
104
104
  render plain: "Too Many Requests", status: 429
105
105
  return
@@ -115,9 +115,34 @@ the same counter.
115
115
  `Limiter#peek(identifier)` returns the same `Result` shape but does not
116
116
  mutate Redis. It is useful when one code path should account for the request
117
117
  (`check`) and another should gate a side-effect on whether the caller is
118
- already over-limit. `peek` skips `:log` rules — their state is unobservable
119
- without incrementing. A matched `:skip` rule terminates `peek` the same way
120
- it terminates `check`: matched, `:allow`, no Redis read, no `info`.
118
+ already over-limit. `peek` reads `:log` rules too: `check` can report a
119
+ `:log` rule's evaluation, so excluding them would make `peek` answer a
120
+ different question than `check`. A matched `:skip` rule terminates `peek`
121
+ the same way it terminates `check`: matched, `:allow`, no Redis read, no
122
+ `info`.
123
+
124
+ ### Clearing state
125
+
126
+ `Limiter#clear(identifier)` deletes this limiter's counters for one
127
+ identifier, along with any ban a rule carrying `ban_for` has written, and returns the
128
+ number of keys removed.
129
+
130
+ ```ruby
131
+ RACK_LIMITER.clear({ ip: "1.2.3.4" }) # => 2
132
+ ```
133
+
134
+ Counters otherwise only disappear when their window expires; this is the only
135
+ way to end one early. It exists for call sites where a later success should
136
+ wipe earlier failures, such as an authentication ban cleared by a valid
137
+ login.
138
+
139
+ Every rule in the limiter is cleared, matched or not, because a caller
140
+ clearing state after a success knows the identifier rather than which rules
141
+ happened to match on the way in. Each rule is deleted in its own call, since
142
+ rules do not share a Redis Cluster slot. Clearing an identifier with no state
143
+ is a no-op returning `0`. A Redis failure fails open like `check`, leaving the
144
+ rest of the state to expire on its own, and the return value counts whatever
145
+ was removed before the failure.
121
146
 
122
147
  ## Identifier
123
148
 
@@ -146,8 +171,10 @@ A `Rule` is a `Data.define` value object with the following fields:
146
171
  | `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
172
  | `limit` | Integer request threshold per `period`. May be a callable resolved on every check. |
148
173
  | `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`, `:allow`, `:skip`. Default `:block`. See [Actions](#actions). |
174
+ | `action` | What the rule does when it matches. One of `:limit`, `:log`, `:skip`. Default `:limit`. See [Actions](#actions). |
150
175
  | `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
176
+ | `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`. |
177
+ | `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. |
151
178
 
152
179
  Making `limit` or `period` callable is the supported pattern for
153
180
  runtime-tunable thresholds (e.g. feature flags or database-backed settings):
@@ -172,18 +199,52 @@ A `match` hash gates whether a rule applies. Each value is normalised through
172
199
  | `Regexp` | `re` | `match: { endpoint: %r{\A/api/} }` |
173
200
  | `{ eq: <value> }` | `eq` | `match: { method: { eq: "POST" } }` (YAML-friendly) |
174
201
  | `{ re: <source> }`| `re` | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |
202
+ | `{ oneOf: <Array> }` | `oneOf` | `match: { user: { oneOf: ["7", "42"] } }` (YAML-friendly) |
175
203
 
176
204
  `re` coerces the identifier value via `#to_s` before matching, so it can be
177
205
  used against non-String values (e.g. matching a 503 status against `{ re: '^5' }`).
178
206
 
207
+ `oneOf` is set membership via `Set#include?` (`eql?`/`hash` equality, no
208
+ coercion): it agrees with `eq` for Strings, Symbols, booleans, nil, and
209
+ same-class numerics, but cross-type numerics differ (`1 == 1.0`, yet
210
+ `{ oneOf: [1] }` does not match `1.0`). A bare Array is rejected; membership must
211
+ be spelled `{ oneOf: [...] }` explicitly.
212
+
179
213
  Glob and prefix matchers are intentionally out of scope.
180
214
 
181
215
  ### Evaluation flow
182
216
 
183
- `check` walks the rule list in order. The first **terminating** rule wins;
184
- `:log` rules count but do not terminate, so they cannot disable a following
185
- `:block` rule. A pure `:log`-only path still emits one `rule="unmatched"`
186
- metric increment because no terminating rule fired.
217
+ `check` walks the rule list in order and evaluates **every** rule that matches:
218
+ each one increments its own counter, and the request debits `cost` from all of
219
+ them. Matching alone does not stop the walk. Only two things terminate it:
220
+
221
+ - a matched `:skip` rule (bypass, nothing counted), and
222
+ - a `:limit` rule that is **over its limit** — the request is rejected, so the
223
+ remaining rules cannot change the outcome. Rules declared after it are neither
224
+ counted nor evaluated, meaning a blocked request debits every rule up to and
225
+ including the one that blocked, and none after it.
226
+
227
+ A `:limit` rule that is under its limit does not terminate, so it behaves
228
+ exactly like `:log` until the moment it blocks.
229
+
230
+ The returned `Result` collects one `Result::Evaluation` per counted rule
231
+ (`result.evaluations`), and its readers (`rule`, `info`, `exceeded?`,
232
+ `to_response_headers`) report the **most constraining** one: a blocking
233
+ evaluation first, then exceeded ones, then the fewest requests remaining,
234
+ ties broken by declaration order (the ranking is `Result::Evaluation#<=>`).
235
+ That is what keeps `to_response_headers` honest — reporting any other matched
236
+ rule would advertise headroom that a different rule is about to refuse:
237
+
238
+ ```
239
+ per_ip limit 1000 count 20 remaining 980
240
+ per_user limit 100 count 99 remaining 1 <- reported
241
+ ```
242
+
243
+ An exceeded rule has `remaining` 0 and therefore outranks any rule still under
244
+ its limit. For a `:log` rule that means shadow traffic surfaces to the caller
245
+ as `exceeded? == true` (with `action` still `:allow`) — visible, but unable to
246
+ block. A `:log`-only path that matches therefore returns `matched? == true`
247
+ and its check is counted as `checks_total{matched="true"}`.
187
248
 
188
249
  ```mermaid
189
250
  flowchart TD
@@ -191,59 +252,162 @@ flowchart TD
191
252
  Iter -->|yes| Match{rule.match<br/>all satisfied?}
192
253
  Match -->|no| Iter
193
254
  Match -->|yes| Skip{rule.action<br/>== :skip?}
194
- Skip -->|"yes (no Redis op)"| SkipEmit[Emit calls_total<br/>action=skip]
255
+ Skip -->|"yes (no Redis op)"| SkipEmit[Emit rule_evaluations_total<br/>action=skip, result=skip]
195
256
  SkipEmit --> SkipReturn([Return matched=true<br/>action=:allow])
196
- Skip -->|no| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
197
- Eval --> Build[Build Result<br/>resolve limit/period]
198
- Build --> Emit[Emit calls_total + limit/period gauges]
199
- Emit --> Act{rule.action}
200
- Act -->|":log<br/>(non-terminating)"| Iter
201
- Act -->|:block or :allow| Return([Return Result])
202
- Iter -->|no more rules| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
203
- Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
204
- Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
257
+ Skip -->|no| Ban{"ban_for rule with<br/>a ban in force?"}
258
+ Ban -->|"yes (no increment)"| Build
259
+ Ban -->|no| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
260
+ Eval --> Build[Build Evaluation<br/>resolve limit/period]
261
+ Build --> Add[Add evaluation to Result]
262
+ Add --> Emit[Emit rule_evaluations_total<br/>+ limit/period gauges]
263
+ Emit --> Act{"result.block?<br/>(:limit rule over its limit,<br/>or its ban in force)"}
264
+ Act -->|yes| Return([Return Result<br/>action=:block])
265
+ Act -->|"no (:log, or under limit)"| Iter
266
+ Iter -->|no more rules| Return2([Return Result reporting<br/>most-constraining evaluation,<br/>or matched=false if none])
267
+ Return --> Check[Emit checks_total<br/>action, matched, error]
268
+ SkipReturn --> Check
269
+ Return2 --> Check
270
+ Eval -. StandardError .-> Error[Emit errors_total +<br/>checks_total error=true<br/>log warn]
205
271
  Error --> ReturnErr([Return error=true<br/>action=:allow])
206
272
  ```
207
273
 
274
+ Error handling is whole-check, not per-rule: a Redis failure part-way through
275
+ discards the verdicts of the rules already evaluated, even though their counters
276
+ were incremented. Those requests are counted but produce no verdict — the
277
+ fail-open trade-off is that a request is never blocked on a partial evaluation.
278
+
279
+ `rule_evaluations_total` is emitted **per evaluated rule** (including matched
280
+ `:skip` rules); `checks_total` is emitted **exactly once per check**, whatever
281
+ path the evaluation takes — so summing `checks_total` by `rate_limiter` counts
282
+ requests through the limiter, and "no rule matched" is `matched="false"` on the
283
+ check rather than a placeholder rule.
284
+
285
+ Only matched rules are evaluated: a rule whose `match:` conditions are not
286
+ satisfied emits nothing to `rule_evaluations_total` (match-testing is not an
287
+ evaluation), so the counter has no "didn't match" population and needs no
288
+ label for it. A matched `count_distinct` rule skipped by the missing-key
289
+ fail-open also emits nothing here — it was never evaluated; that check is
290
+ visible via `checks_total{error="true"}` and the
291
+ `rate_limit_missing_count_distinct` log, which carries the rule name.
292
+
293
+ During the transition the deprecated `calls_total` counter is additionally
294
+ emitted at every point the diagram emits `rule_evaluations_total` (with its
295
+ historical per-rule semantics, including the `rule="unmatched"` placeholder
296
+ after the loop). It is not shown above to keep the diagram legible; see the
297
+ Metrics table below.
298
+
208
299
  ### Actions
209
300
 
210
- The rule's `action` controls how the `Result` reports an over-limit hit. The
211
- counter is always incremented when a rule matches, except for `:skip` rules,
212
- which never touch Redis:
213
-
214
- - `:block` — when exceeded, `Result#action` is `:block`. Caller should reject
215
- the request (e.g. with HTTP 429). When under the limit, action is `:allow`.
216
- - `:log` — **non-terminating**. The rule counts the request and records
217
- metrics, but evaluation continues to the next rule. This is the mechanism
218
- for shadow rules during rollout: stack a `:log` rule and a `:block` rule
219
- together and the `:log` rule cannot disable the `:block` rule. Note that a
220
- pure `:log`-only check still emits one `rule="unmatched"` metric entry
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.
301
+ The rule's `action` describes what the rule does; the result's `action`
302
+ describes the outcome — what the caller should do — and is only ever `:allow`
303
+ or `:block`. A matching rule increments its counter, with two exceptions:
304
+ `:skip` rules never touch Redis, and a rule carrying `ban_for` whose ban is
305
+ already in force short-circuits before incrementing. The `result` label on
306
+ `rule_evaluations_total` records what each evaluation decided:
307
+
308
+ | rule action | what it does | exceeded? | result action | `result` label | terminating? |
309
+ |-------------|-----------------------------------------------|-----------|---------------|----------------|---------------|
310
+ | `:limit` | count against the limit | no | `:allow` | `allow` | no — continue |
311
+ | `:limit` | count against the limit | yes | `:block` | `block` | yes — stop |
312
+ | `:log` | count against the limit (observability only) | no | `:allow` | `allow` | no — continue |
313
+ | `:log` | count against the limit (observability only) | yes | `:allow` | `log` | no — continue |
314
+ | `:skip` | don't count (bypass) | n/a | `:allow` | `skip` | yes — stop |
315
+
316
+ A rule may also carry `ban_for`, which changes the accounting rather than the
317
+ action. On crossing the limit it writes a ban that outlives the counting
318
+ window, and it stops counting while that ban holds:
319
+
320
+ | rule action | with `ban_for` | exceeded? | result action | `result` label | terminating? |
321
+ |-------------|-----------------------------------------------|-----------|---------------|----------------|---------------|
322
+ | `:limit` | count, or skip counting while banned | yes | `:block` | `banned` | yes — stop |
323
+ | `:log` | same accounting, records what it would do | yes | `:allow` | `banned` | no — continue |
324
+
325
+ - `:limit` — when exceeded, `Result#action` is `:block` and evaluation
326
+ terminates. Caller should reject the request (e.g. with HTTP 429). When under
327
+ the limit, evaluation continues to the next rule.
328
+ - `:log` — **never terminating and never blocking**. The rule counts the
329
+ request and records metrics, but evaluation always continues to the next
330
+ rule. This is the mechanism for shadow rules during rollout: stack a `:log`
331
+ rule and a `:limit` rule together and the `:log` rule cannot disable the
332
+ `:limit` rule. When exceeded, a `:log` rule can still be the rule reported in
333
+ the `Result` (`exceeded? == true`, `action == :allow`) if nothing more
334
+ constraining matched.
226
335
  - `:skip` — bypass. A matching rule terminates evaluation with
227
336
  `Result#action` `:allow` **without any Redis operation**: nothing is
228
337
  counted, so `limit`, `period`, `characteristics`, and `count_distinct` are
229
338
  inert and the result carries no `info` (`to_response_headers` is `{}`).
230
- The match is still observable via `calls_total{action="skip"}`. Use this
231
- for bypasses that don't need a counter; use `:allow` only when you want
232
- the bypassed traffic counted.
339
+ The match is still observable via `rule_evaluations_total{action="skip"}`.
340
+ Use this for bypasses.
341
+ - `ban_for` — enforcement that outlives the window, on either counting action.
342
+ On crossing the limit the rule writes a second key that keeps blocking for
343
+ `ban_for` seconds, long after the counter window has expired. While that ban
344
+ is in force the rule reports `exceeded? == true` and does not count, so a
345
+ caller cannot extend its own ban, and `reset_at` reports when the ban lifts
346
+ rather than when the window rolls. Use this where repeated failures should
347
+ cost more than a window, such as authentication attempts. State can be
348
+ discarded early with [`#clear`](#clearing-state).
349
+
350
+ With `action: :limit` the ban blocks. With `action: :log` the rule does all
351
+ of the same accounting, including writing the ban and suppressing counting
352
+ while it holds, and only skips the blocking, so a shadow measures what
353
+ enforcement would have produced rather than approximating it.
354
+
355
+ A `ban_for` shorter than `period` is legal but surprising: the ban can lapse
356
+ while the counter is still over the limit, so `peek` reports not-exceeded
357
+ while the next `check` re-bans immediately. Bans are normally longer than the
358
+ window they are counted in.
359
+
360
+ The duration is whole seconds and must be at least 1, since the ban is
361
+ written with `SET EX`. A callable is checked again each time it resolves, and
362
+ a value under a second raises rather than reaching Redis. Being an error, it
363
+ fails open like any other, so a rule whose `ban_for` cannot resolve stops
364
+ blocking entirely: watch `errors_total` after changing one.
365
+
366
+ Take care combining `ban_for` with `cost:`. One expensive call can cross the
367
+ limit on its own, and with a ban attached that costs the caller the whole ban
368
+ rather than a single rejection. Checking with `peek` first has the same edge,
369
+ since the call it deliberately lets through is enough to start a ban. Bans
370
+ suit counting discrete failures, such as bad credentials, better than they
371
+ suit variable-cost resource limits.
233
372
 
234
373
  ### Redis keys
235
374
 
236
375
  Each matched check writes a key shaped:
237
376
 
238
377
  ```
239
- labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
378
+ labkit:rl:{<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]}
240
379
  ```
241
380
 
381
+ The braces are a Redis [hash tag](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags):
382
+ on a Redis Cluster only the braced part decides the slot, so every key for one
383
+ rule and identifier lands on the same shard. `BAN_SCRIPT` touches two keys in
384
+ one call, and a cluster rejects a script whose keys span slots.
385
+
242
386
  Characteristic values longer than 200 bytes are replaced with a SHA-256
243
387
  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
- (`count == 1`) and is not extended on subsequent INCRs, so the window is a
246
- true fixed window starting at the first request, not a sliding window.
388
+ encoded as `_unknown_`. The TTL is set on the first write of each window and
389
+ is not extended on subsequent increments, so the window is a true fixed
390
+ window starting at the first request, not a sliding window.
391
+
392
+ A check is a single `EVALSHA` of `INCR_SCRIPT` (or `SADD_SCRIPT` for
393
+ `count_distinct` rules, or `BAN_SCRIPT` for rules carrying `ban_for`). Doing the whole
394
+ read-modify-write inside Lua means there is no window between the increment
395
+ and the `EXPIRE` in which a key could be left without a TTL.
396
+
397
+ A rule carrying `ban_for` writes a second key alongside its counter, sharing the
398
+ same hash tag and carrying its own `ban_for` TTL:
399
+
400
+ ```
401
+ labkit:rl:{<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]}:ban
402
+ ```
403
+
404
+ The suffix sits outside the tag, so it cannot change the slot and cannot
405
+ collide with a characteristic value, and both keys are found by the same
406
+ `labkit:rl:{<limiter>:<rule>*` glob. The ban key outlives its counter by
407
+ design: that is the whole point of `ban_for`.
408
+ `BAN_SCRIPT` checks the ban before incrementing, so the ban check,
409
+ the increment and the ban write are one atomic operation and a concurrent
410
+ check cannot miss the threshold crossing.
247
411
 
248
412
  ```mermaid
249
413
  sequenceDiagram
@@ -251,36 +415,51 @@ sequenceDiagram
251
415
  participant E as Evaluator
252
416
  participant P as Connection pool
253
417
  participant R as Redis
418
+ participant L as INCR_SCRIPT (Lua)
254
419
 
255
420
  E->>P: pool.with { |conn| ... }
256
421
  P-->>E: conn
257
- E->>R: PIPELINE { INCR key, TTL key }
258
- R-->>E: [count, ttl]
259
- alt count == 1 (first write of window)
260
- E->>R: EXPIRE key period
261
- R-->>E: 1
262
- Note over E: ttl returned is -1 here;<br/>build_result falls back to<br/>resolved_period for reset_at.
263
- else count > 1
264
- Note over E: TTL is not extended:<br/>fixed window from first write.
422
+ E->>R: EVALSHA INCR_SCRIPT key, [period, cost]
423
+ R->>L: run script
424
+ L->>L: INCRBYFLOAT key cost
425
+ L->>L: TTL key
426
+ alt TTL < 0 (key was missing, or had no expiry)
427
+ L->>L: EXPIRE key period
428
+ Note over L: Returns period as the TTL:<br/>the window starts now.
429
+ else TTL >= 0 (window already running)
430
+ Note over L: TTL is not extended:<br/>fixed window from first write.
265
431
  end
432
+ L-->>R: {count, ttl}
433
+ R-->>E: [count, ttl]
266
434
  E-->>P: release conn
267
435
  ```
268
436
 
269
- `peek` follows the same shape but uses `GET` instead of `INCR` and never
270
- issues `EXPIRE`. A missing key (`GET → nil`, `TTL → -2`) is reported as
271
- `count = 0` and the window is treated as not-yet-started.
437
+ Because `INCRBYFLOAT` preserves an existing key's TTL, and creates a missing
438
+ key with no expiry, the TTL only needs reading once — after the increment.
439
+ That single read distinguishes both cases the script must handle, so there is
440
+ no reason to read it again before mutating.
441
+
442
+ `peek` does not use a script: it pipelines `GET` + `TTL` (or `SCARD` + `TTL`)
443
+ and never issues `EXPIRE`, so it cannot start or extend a window. A missing
444
+ key (`GET → nil`, `TTL → -2`) is reported as `count = 0`, and `build_evaluation`
445
+ falls back to the rule's period for `reset_at` since there is no Redis-side
446
+ window to read.
272
447
 
273
448
  ## Result
274
449
 
275
- `Result` carries the decision back to the caller:
450
+ `Result` carries the decision back to the caller. It collects one
451
+ `Result::Evaluation` per counted rule; `rule`, `exceeded?`, and `info` report
452
+ the most constraining one (see [Evaluation flow](#evaluation-flow)):
276
453
 
277
454
  ```ruby
278
455
  result.matched? # => true if some rule matched
279
- result.exceeded? # => true if the matched rule's counter > limit
280
- result.action # => :block | :log | :allow
281
- result.rule # => the matched Rule, or nil
456
+ result.exceeded? # => true if the reported rule's counter > limit
457
+ result.action # => :block | :allow — what the caller should do
458
+ result.block? # => result.action == :block
459
+ result.rule # => the reported Rule, or nil
282
460
  result.error? # => true if Redis failed (see Fail-open)
283
461
  result.info # => Result::Info or nil
462
+ result.evaluations # => every counted Result::Evaluation, in rule order
284
463
  result.to_response_headers
285
464
  # => { "RateLimit-Limit" => "...", "RateLimit-Remaining" => "...", "RateLimit-Reset" => "<unix-ts>" }
286
465
  ```
@@ -302,27 +481,53 @@ safe to merge unconditionally.
302
481
 
303
482
  The evaluator wraps `check` and `peek` in a broad rescue. Any `StandardError`
304
483
  (Redis connection failure, timeout, OOM in user-supplied callables, …) is
305
- logged at WARN with `message: "rate_limit_error"` and returned as a
306
- `Result(matched: false, error: true, action: :allow)`. The
307
- `gitlab_labkit_rate_limiter_errors_total` counter is incremented. The caller
484
+ logged at WARN with `error_type: "rate_limit_error"` and returned as an error
485
+ `Result` (`matched?` false, `error?` true, `action` `:allow`). The caller
308
486
  should treat the request as allowed.
309
487
 
488
+ A failed-open `check` is still counted: it emits
489
+ `checks_total{action="allow", matched="false", error="true"}`, so the fraction
490
+ of checks that encountered an error is `checks_total{error="true"}` over
491
+ `checks_total`. `error="true"` also covers a check where a matched
492
+ `count_distinct` rule was skipped because the identifier was missing its
493
+ `count_distinct` key — that check completes (`action` and `matched` describe
494
+ its outcome as usual), so `error="true"` is not exclusively fail-open traffic.
495
+
496
+ The deprecated `gitlab_labkit_rate_limiter_errors_total` counter is still
497
+ incremented on every fail-open (whole-check and per-rule `count_distinct`),
498
+ and remains the only error metric for `peek`, which emits no `checks_total`
499
+ (peek must not inflate the per-check counter). The follow-up MR that removes
500
+ `errors_total` must first decide where `peek` errors go — a dedicated peek
501
+ metric, or logs only.
502
+
503
+ Metric emission itself is best-effort: a failure in the metrics stack never
504
+ alters the verdict or breaks fail-open, and is logged at WARN with
505
+ `error_type: "rate_limit_metrics_error"` (once per process, to avoid
506
+ flooding).
507
+
310
508
  ## Metrics
311
509
 
312
510
  `Labkit::RateLimit::Metrics` emits the following Prometheus metrics through
313
511
  `Labkit::Metrics::Client`:
314
512
 
315
- | metric | type | labels | meaning |
316
- |-------------------------------------------------|---------|-------------------------------------|----------------------------------------------------------------------|
317
- | `gitlab_labkit_rate_limiter_calls_total` | counter | `rate_limiter`, `rule`, `action` | One increment per terminating decision; also incremented per matched `:log` rule. `action` is one of `"allow"`, `"block"`, `"log"`, `"skip"`. `rule="unmatched", action="allow"` when no rule terminated. |
318
- | `gitlab_labkit_rate_limiter_errors_total` | counter | `rate_limiter` | Fail-open events (any `StandardError` in the labkit path). |
319
- | `gitlab_labkit_rate_limiter_limit` | gauge | `rate_limiter`, `rule` | Resolved limit at the last check (useful when `limit:` is callable). |
320
- | `gitlab_labkit_rate_limiter_period_seconds` | gauge | `rate_limiter`, `rule` | Resolved period at the last check. |
321
-
322
- Because `:log` rules do not terminate, a single `check` call can emit
323
- **multiple** `calls_total` increments: one per `:log` rule that matched, plus
324
- one for the terminating decision (or `rule="unmatched"` if no terminating
325
- rule fired).
513
+ | metric | type | labels | meaning |
514
+ |-----------------------------------------------------|---------|----------------------------------------------|----------------------------------------------------------------------|
515
+ | `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"`. |
516
+ | `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). |
517
+ | `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. |
518
+ | `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`. |
519
+ | `gitlab_labkit_rate_limiter_limit` | gauge | `rate_limiter`, `rule` | Resolved limit at the last check (useful when `limit:` is callable). |
520
+ | `gitlab_labkit_rate_limiter_period_seconds` | gauge | `rate_limiter`, `rule` | Resolved period at the last check. |
521
+
522
+ `sum by (rate_limiter) (rate(gitlab_labkit_rate_limiter_checks_total[5m]))` is
523
+ the request rate through a limiter — no exclusions or dedup needed. A single
524
+ `check` call emits **one** `checks_total` increment and as many
525
+ `rule_evaluations_total` increments as rules it evaluated (possibly zero).
526
+
527
+ **Transition:** the deprecated `calls_total` and `errors_total` counters keep
528
+ emitting exactly as before this split, so existing dashboards and alerts stay
529
+ correct while consumers migrate to the new counters. Both are removed in a
530
+ follow-up major release once nothing consumes them.
326
531
 
327
532
  ## Dev/test vs production guards
328
533