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 +4 -4
- data/.gitlab/CODEOWNERS +1 -1
- data/.gitlab-ci-asdf-versions.yml +1 -1
- data/.gitlab-ci.yml +3 -3
- data/.pre-commit-config.yaml +1 -1
- data/.tool-versions +1 -1
- data/CODEOWNERS +1 -1
- data/lib/labkit/fields.rb +8 -1
- data/lib/labkit/rate_limit/README.md +279 -74
- data/lib/labkit/rate_limit/evaluator.rb +339 -80
- data/lib/labkit/rate_limit/identifier.rb +26 -1
- data/lib/labkit/rate_limit/limiter.rb +24 -3
- data/lib/labkit/rate_limit/matcher.rb +69 -9
- data/lib/labkit/rate_limit/metrics.rb +62 -3
- data/lib/labkit/rate_limit/result.rb +133 -19
- data/lib/labkit/rate_limit/rule.rb +42 -11
- data/lib/labkit/rate_limit.rb +2 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22a38c0fe1a2f28aa538b80348911947c6de64c16d7d870d24835332796faa99
|
|
4
|
+
data.tar.gz: 6259d696d178f3008aefdf91de71731c81d460cef08259555829f15d8abd462c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
+
- 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.
|
|
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.
|
|
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,
|
data/.pre-commit-config.yaml
CHANGED
|
@@ -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.
|
|
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
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 <-->|
|
|
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.
|
|
81
|
-
|
|
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.
|
|
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`
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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`
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 `
|
|
231
|
-
for bypasses
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
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:
|
|
258
|
-
R
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
`
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
280
|
-
result.action # => :block | :
|
|
281
|
-
result.
|
|
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 `
|
|
306
|
-
`Result(matched
|
|
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
|
|
316
|
-
|
|
317
|
-
| `
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
320
|
-
| `
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|