gitlab-labkit 1.20.1 → 1.21.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: 3b916b3579ca06fa9a40b34d2bac1d9ffb5bcb8a51415ad9438956c546400164
4
- data.tar.gz: a680e485958792ac93e5be3fca0c7598db2c029349d6c7aa3c34a039daf2e350
3
+ metadata.gz: 43d394fdecdc0275e1ddab3a28ae7824f907e21637c4d06b3cc183a801df9377
4
+ data.tar.gz: 18632e75c51ec22c6b78079c59476f736b3b3b298c7988fbf4a49c9505c4d05e
5
5
  SHA512:
6
- metadata.gz: 53acf37dbe0f4416c5952dfba12e3df3156f233243c69580bb3dfdef6b9727f73d586151a9809225e9a99d537e2499da4f66061c197462debc76d3b3c633160a
7
- data.tar.gz: bd46d460f292290670b62801a5cb84958b187e086b662d9537df95d0ddc9af6bc1c956050d051a4c14383209b07923fbcfab0992d59c689a5c794811de0a43a2
6
+ metadata.gz: d56453b6af992b51da987bec68fd990fefc808b68be96702c3b4274ae609a773139b39bb5609032ed9148a7427976bd64ea0566fa0c8354582e4d4d4135d481e
7
+ data.tar.gz: f8d71107b36351983b8a1da70b9a43624b2eb73e000b37fd45cad03606749c84a2da0282d1e02cd7f5a1b687e2e030717fc1770f2671d05175a7cd4d9eb9bad1
data/.copier-answers.yml CHANGED
@@ -3,9 +3,10 @@
3
3
  # See the project for instructions on how to update the project
4
4
  #
5
5
  # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
6
- _commit: v1.46.0
6
+ _commit: v1.48.0
7
7
  _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
8
8
  ee_licensed: false
9
+ gitlab_namespace: gitlab-org/ruby/gems
9
10
  golang: false
10
11
  helm: false
11
12
  initial_codeowners: '@reprazent @andrewn @mkaeppler @ayufan'
data/.gitignore CHANGED
@@ -3,3 +3,4 @@ Gemfile.lock
3
3
  node_modules
4
4
  .bundle
5
5
  /.env.sh
6
+ .idea/
data/.gitlab-ci.yml CHANGED
@@ -27,6 +27,16 @@ include:
27
27
 
28
28
  - component: $CI_SERVER_FQDN/gitlab-com/gl-infra/common-ci-tasks/danger@v3.24
29
29
 
30
+ # Attach a redis service to the rspec job from common-ci-tasks/ruby-build.
31
+ # GitLab merges keys when a local job has the same name as an included one,
32
+ # so this augments the base job (and is inherited by anything that extends it).
33
+ rspec:
34
+ services:
35
+ - name: redis:7-alpine
36
+ alias: redis
37
+ variables:
38
+ LABKIT_TEST_REDIS_URL: redis://redis
39
+
30
40
  ruby-versions:
31
41
  extends: rspec
32
42
  image: ${CI_REGISTRY}/gitlab-com/gl-infra/common-ci-tasks-images/ruby:${RUBY_VERSION}
data/README.md CHANGED
@@ -24,6 +24,7 @@ LabKit-Ruby provides functionality in a number of areas:
24
24
  1. `Labkit::FIPS` for checking for FIPS mode and using FIPS-compliant algorithms.
25
25
  1. `Labkit::Logging` for sanitizing log messages.
26
26
  1. `Labkit::Metrics` for metrics. More on the [README](./lib/labkit/metrics/README.md).
27
+ 1. `Labkit::RateLimit` for rules-based, Redis-backed rate limiting. More on the [README](./lib/labkit/rate_limit/README.md).
27
28
  1. `Labkit::RSpec` for RSpec matchers to test Labkit functionality (requires selective loading). More on the [README](./lib/labkit/rspec/README.md).
28
29
  1. `Labkit::Tracing` for handling and propagating distributed traces.
29
30
 
@@ -43,6 +44,16 @@ $ # Run tests, linters
43
44
  $ bundle exec rake verify
44
45
  ```
45
46
 
47
+ Some specs require a real Redis instance. When you run the suite locally,
48
+ it will automatically start one via `docker compose up -d redis` (see
49
+ `docker-compose.yml`) and tear it down again when the test process exits.
50
+ Redis is exposed on `localhost:6390` so it does not collide with a local
51
+ GDK/Caproni Redis on the default port.
52
+
53
+ To opt out of autostart (e.g. you've started Redis some other way), set
54
+ `LABKIT_TEST_REDIS_URL` to a reachable instance, or
55
+ `LABKIT_TEST_REDIS_NO_AUTOSTART=1` to fail loudly instead of spawning.
56
+
46
57
  Please also review the [development section of the LabKit (go) README](https://gitlab.com/gitlab-org/labkit#developing-labkit) for details of the LabKit architectural philosophy.
47
58
 
48
59
  To work on some of the scripts we use for releasing a new version,
@@ -0,0 +1,10 @@
1
+ services:
2
+ redis:
3
+ image: redis:7-alpine
4
+ ports:
5
+ - "6390:6379"
6
+ healthcheck:
7
+ test: ["CMD", "redis-cli", "ping"]
8
+ interval: 5s
9
+ timeout: 2s
10
+ retries: 5
data/exe/labkit-logging CHANGED
@@ -176,7 +176,9 @@ module Labkit
176
176
  http.read_timeout = 30
177
177
  req = Net::HTTP::Get.new(uri.request_uri)
178
178
  req['PRIVATE-TOKEN'] = token
179
- http.request(req)
179
+ resp = http.request(req)
180
+ abort "Unauthorized: check that your GitLab token is valid and has access to this project" if resp.is_a?(Net::HTTPUnauthorized) || resp.is_a?(Net::HTTPForbidden)
181
+ resp
180
182
  end
181
183
 
182
184
  def token
@@ -0,0 +1,329 @@
1
+ # Labkit::RateLimit
2
+
3
+ `Labkit::RateLimit` is a rules-based rate limiter backed by Redis counters. It
4
+ maintains a fixed-window counter per `(call-site, rule, characteristics)` tuple
5
+ and decides whether each request is within the configured limit.
6
+
7
+ The module is intentionally small: a `Limiter` is configured at boot with an
8
+ ordered list of `Rule`s, and every request calls `Limiter#check(identifier)` to
9
+ get back a `Result` describing what the caller should do.
10
+
11
+ ## Architecture
12
+
13
+ ```mermaid
14
+ flowchart LR
15
+ App[Application code] -->|"check(identifier)"| Limiter
16
+ Limiter -->|delegates| Evaluator
17
+ Evaluator -->|iterates ordered| Rules[Rule list]
18
+ Evaluator <-->|INCR / TTL / EXPIRE| Redis[(Redis)]
19
+ Evaluator -->|emits| Metrics[Prometheus metrics]
20
+ Evaluator -->|returns| Result
21
+ Result --> App
22
+ ```
23
+
24
+ A `Limiter` is configured once per call site and holds an `Evaluator` plus the
25
+ compiled `Rule` list. Every `check` call delegates to the same `Evaluator`,
26
+ which iterates the rules in declaration order, talks to Redis, emits metrics,
27
+ and builds a `Result`.
28
+
29
+ ## Configuration
30
+
31
+ `Labkit::RateLimit.configure` sets a global Redis connection pool and logger
32
+ that are reused across all `Limiter` instances unless a per-Limiter override is
33
+ supplied:
34
+
35
+ ```ruby
36
+ Labkit::RateLimit.configure do |c|
37
+ c.redis = ConnectionPool.new(size: 5) { Redis.new(url: ENV["REDIS_URL"]) }
38
+ c.logger = Labkit::Logging::JsonLogger.new($stdout)
39
+ end
40
+ ```
41
+
42
+ The `redis` value must respond to `.with { |conn| ... }` and yield a connection
43
+ that supports `incr`, `ttl`, `get`, `expire`, and `pipelined`. A
44
+ `ConnectionPool` of `Redis` clients is the typical choice.
45
+
46
+ The logger is used only for warnings (invalid rule names, fail-open errors,
47
+ duplicate rule names in production). It defaults to a JSON logger writing to
48
+ `$stdout`.
49
+
50
+ ## Defining a Limiter
51
+
52
+ A `Limiter` is the unit of configuration for one call site (e.g. "rack
53
+ requests", "graphql mutations", "ai actions"). Construct it once and reuse it:
54
+
55
+ ```ruby
56
+ RACK_LIMITER = Labkit::RateLimit::Limiter.new(
57
+ name: "rack_request",
58
+ rules: [
59
+ Labkit::RateLimit::Rule.new(
60
+ name: "api_user",
61
+ limit: 600,
62
+ period: 60,
63
+ characteristics: [:user],
64
+ match: { endpoint: { re: '\A/api/' } }
65
+ ),
66
+ Labkit::RateLimit::Rule.new(
67
+ name: "unauthenticated",
68
+ limit: 60,
69
+ period: 60,
70
+ characteristics: [:ip],
71
+ match: { user: nil }
72
+ )
73
+ ]
74
+ )
75
+ ```
76
+
77
+ - `name` must match `/\A[a-z0-9_]+\z/`. It is used as the first segment of
78
+ every Redis counter key for this limiter, so renaming a `Limiter` abandons
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)).
82
+ - `redis` and `logger` are optional; they fall back to the global
83
+ `Labkit::RateLimit.config` values.
84
+
85
+ A `Labkit::RateLimit.check(name:, identifier:, rules:, ...)` convenience method
86
+ exists for one-off cases that cannot cache a `Limiter` instance, but it
87
+ allocates a fresh `Limiter` on every call and is not the recommended path.
88
+
89
+ ## Checking a request
90
+
91
+ `Limiter#check(identifier)` increments the counter for the matched rule and
92
+ returns a `Result`. `identifier` is either an `Identifier` or a plain `Hash`
93
+ of caller attributes:
94
+
95
+ ```ruby
96
+ result = RACK_LIMITER.check(
97
+ user: current_user&.id,
98
+ ip: request.ip,
99
+ endpoint: request.path
100
+ )
101
+
102
+ if result.exceeded? && result.action == :block
103
+ response.headers.merge!(result.to_response_headers)
104
+ render plain: "Too Many Requests", status: 429
105
+ return
106
+ end
107
+ ```
108
+
109
+ The `endpoint` key is treated specially: the query string is stripped at
110
+ `Identifier` construction time so URLs that vary only by query parameter share
111
+ the same counter.
112
+
113
+ ### Peeking without incrementing
114
+
115
+ `Limiter#peek(identifier)` returns the same `Result` shape but does not
116
+ mutate Redis. It is useful when one code path should account for the request
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.
120
+
121
+ ## Identifier
122
+
123
+ `Identifier` is a small value object wrapping a hash of caller attributes.
124
+ You can pass a `Hash` to `check`/`peek` and `Limiter` will wrap it for you, or
125
+ construct one explicitly:
126
+
127
+ ```ruby
128
+ id = Labkit::RateLimit::Identifier.new(
129
+ user: 42,
130
+ ip: "1.2.3.4",
131
+ endpoint: "/api/v4/projects/1?per_page=20" # becomes "/api/v4/projects/1"
132
+ )
133
+ ```
134
+
135
+ Keys can be symbols or strings — they are normalised to symbols on the way
136
+ in.
137
+
138
+ ## Rule
139
+
140
+ A `Rule` is a `Data.define` value object with the following fields:
141
+
142
+ | field | meaning |
143
+ |-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
144
+ | `name` | Stable identifier used in Redis keys and metric labels. Must match `/\A[a-z0-9_]+\z/`, max 64 chars. Renaming a rule abandons its in-flight counters. |
145
+ | `match` | Hash of identifier key/value predicates that must **all** be satisfied for the rule to apply. Empty hash matches anything. See [Matchers](#matchers). |
146
+ | `limit` | Integer request threshold per `period`. May be a callable resolved on every check. |
147
+ | `period` | Window length in seconds. May be a callable resolved on every check. |
148
+ | `action` | What the result reports when the limit is exceeded. One of `:block`, `:log`, `:allow`. Default `:block`. See [Actions](#actions). |
149
+ | `characteristics` | Array of identifier keys whose values are folded into the Redis counter key. Each unique combination gets its own counter. |
150
+
151
+ Making `limit` or `period` callable is the supported pattern for
152
+ runtime-tunable thresholds (e.g. feature flags or database-backed settings):
153
+
154
+ ```ruby
155
+ Labkit::RateLimit::Rule.new(
156
+ name: "api_user",
157
+ limit: -> { Settings.rate_limit_api_user_per_minute },
158
+ period: 60,
159
+ characteristics: [:user]
160
+ )
161
+ ```
162
+
163
+ ### Matchers
164
+
165
+ A `match` hash gates whether a rule applies. Each value is normalised through
166
+ `Matcher.build`:
167
+
168
+ | input shape | matcher | example |
169
+ |-------------------|----------|--------------------------------------------------------|
170
+ | plain value | `eq` | `match: { user: nil }`, `match: { method: "POST" }` |
171
+ | `Regexp` | `re` | `match: { endpoint: %r{\A/api/} }` |
172
+ | `{ eq: <value> }` | `eq` | `match: { method: { eq: "POST" } }` (YAML-friendly) |
173
+ | `{ re: <source> }`| `re` | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |
174
+
175
+ `re` coerces the identifier value via `#to_s` before matching, so it can be
176
+ used against non-String values (e.g. matching a 503 status against `{ re: '^5' }`).
177
+
178
+ Glob and prefix matchers are intentionally out of scope.
179
+
180
+ ### Evaluation flow
181
+
182
+ `check` walks the rule list in order. The first **terminating** rule wins;
183
+ `:log` rules count but do not terminate, so they cannot disable a following
184
+ `:block` rule. A pure `:log`-only path still emits one `rule="unmatched"`
185
+ metric increment because no terminating rule fired.
186
+
187
+ ```mermaid
188
+ flowchart TD
189
+ Start([check identifier]) --> Iter{Next rule?}
190
+ Iter -->|yes| Match{rule.match<br/>all satisfied?}
191
+ Match -->|no| Iter
192
+ Match -->|yes| Eval["INCR Redis counter<br/>(see Redis sequence below)"]
193
+ Eval --> Build[Build Result<br/>resolve limit/period]
194
+ Build --> Emit[Emit calls_total + limit/period gauges]
195
+ Emit --> Act{rule.action}
196
+ Act -->|":log<br/>(non-terminating)"| Iter
197
+ Act -->|:block or :allow| Return([Return Result])
198
+ Iter -->|no more rules| Unmatched[Emit calls_total<br/>rule=unmatched, action=allow]
199
+ Unmatched --> ReturnUnmatched([Return matched=false<br/>action=:allow])
200
+ Eval -. StandardError .-> Error[Emit errors_total<br/>log warn]
201
+ Error --> ReturnErr([Return error=true<br/>action=:allow])
202
+ ```
203
+
204
+ ### Actions
205
+
206
+ The rule's `action` controls how the `Result` reports an over-limit hit. The
207
+ counter is always incremented when a rule matches, regardless of `action`:
208
+
209
+ - `:block` — when exceeded, `Result#action` is `:block`. Caller should reject
210
+ the request (e.g. with HTTP 429). When under the limit, action is `:allow`.
211
+ - `:log` — **non-terminating**. The rule counts the request and records
212
+ metrics, but evaluation continues to the next rule. This is the mechanism
213
+ for shadow rules during rollout: stack a `:log` rule and a `:block` rule
214
+ together and the `:log` rule cannot disable the `:block` rule. Note that a
215
+ pure `:log`-only check still emits one `rule="unmatched"` metric entry
216
+ because no terminating rule fired.
217
+ - `:allow` — when exceeded, `Result#action` is `:allow` (rather than
218
+ `:block`). Useful for "always allow this caller even if they're over the
219
+ limit" cases while still observing them via metrics. Evaluation terminates
220
+ on the first match.
221
+
222
+ ### Redis keys
223
+
224
+ Each matched check writes a key shaped:
225
+
226
+ ```
227
+ labkit:rl:<limiter_name>:<rule_name>:<char>:<value>[:<char>:<value>...]
228
+ ```
229
+
230
+ Characteristic values longer than 200 bytes are replaced with a SHA-256
231
+ hexdigest to bound key length. Missing or empty characteristic values are
232
+ encoded as `_unknown_`. The TTL is set on the first write of each window
233
+ (`count == 1`) and is not extended on subsequent INCRs, so the window is a
234
+ true fixed window starting at the first request, not a sliding window.
235
+
236
+ ```mermaid
237
+ sequenceDiagram
238
+ autonumber
239
+ participant E as Evaluator
240
+ participant P as Connection pool
241
+ participant R as Redis
242
+
243
+ E->>P: pool.with { |conn| ... }
244
+ P-->>E: conn
245
+ E->>R: PIPELINE { INCR key, TTL key }
246
+ R-->>E: [count, ttl]
247
+ alt count == 1 (first write of window)
248
+ E->>R: EXPIRE key period
249
+ R-->>E: 1
250
+ Note over E: ttl returned is -1 here;<br/>build_result falls back to<br/>resolved_period for reset_at.
251
+ else count > 1
252
+ Note over E: TTL is not extended:<br/>fixed window from first write.
253
+ end
254
+ E-->>P: release conn
255
+ ```
256
+
257
+ `peek` follows the same shape but uses `GET` instead of `INCR` and never
258
+ issues `EXPIRE`. A missing key (`GET → nil`, `TTL → -2`) is reported as
259
+ `count = 0` and the window is treated as not-yet-started.
260
+
261
+ ## Result
262
+
263
+ `Result` carries the decision back to the caller:
264
+
265
+ ```ruby
266
+ result.matched? # => true if some rule matched
267
+ result.exceeded? # => true if the matched rule's counter > limit
268
+ result.action # => :block | :log | :allow
269
+ result.rule # => the matched Rule, or nil
270
+ result.error? # => true if Redis failed (see Fail-open)
271
+ result.info # => Result::Info or nil
272
+ result.to_response_headers
273
+ # => { "RateLimit-Limit" => "...", "RateLimit-Remaining" => "...", "RateLimit-Reset" => "<unix-ts>" }
274
+ ```
275
+
276
+ `Result::Info` holds the per-window counter snapshot:
277
+
278
+ | field | meaning |
279
+ |-------------------|------------------------------------------------------------------|
280
+ | `resolved_limit` | The evaluated `Integer` limit for this check. |
281
+ | `resolved_period` | The evaluated `Integer` period in seconds for this check. |
282
+ | `count` | Raw INCR value; useful for utilization-ratio metrics. |
283
+ | `remaining` | `[resolved_limit - count, 0].max`. |
284
+ | `reset_at` | Best-effort UTC `Time` when the window resets (advisory only). |
285
+
286
+ `to_response_headers` returns `{}` for an unmatched or error result, so it is
287
+ safe to merge unconditionally.
288
+
289
+ ## Fail-open
290
+
291
+ The evaluator wraps `check` and `peek` in a broad rescue. Any `StandardError`
292
+ (Redis connection failure, timeout, OOM in user-supplied callables, …) is
293
+ logged at WARN with `message: "rate_limit_error"` and returned as a
294
+ `Result(matched: false, error: true, action: :allow)`. The
295
+ `gitlab_labkit_rate_limiter_errors_total` counter is incremented. The caller
296
+ should treat the request as allowed.
297
+
298
+ ## Metrics
299
+
300
+ `Labkit::RateLimit::Metrics` emits the following Prometheus metrics through
301
+ `Labkit::Metrics::Client`:
302
+
303
+ | metric | type | labels | meaning |
304
+ |-------------------------------------------------|---------|-------------------------------------|----------------------------------------------------------------------|
305
+ | `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"`. `rule="unmatched", action="allow"` when no rule terminated. |
306
+ | `gitlab_labkit_rate_limiter_errors_total` | counter | `rate_limiter` | Fail-open events (any `StandardError` in the labkit path). |
307
+ | `gitlab_labkit_rate_limiter_limit` | gauge | `rate_limiter`, `rule` | Resolved limit at the last check (useful when `limit:` is callable). |
308
+ | `gitlab_labkit_rate_limiter_period_seconds` | gauge | `rate_limiter`, `rule` | Resolved period at the last check. |
309
+
310
+ Because `:log` rules do not terminate, a single `check` call can emit
311
+ **multiple** `calls_total` increments: one per `:log` rule that matched, plus
312
+ one for the terminating decision (or `rule="unmatched"` if no terminating
313
+ rule fired).
314
+
315
+ ## Dev/test vs production guards
316
+
317
+ `Limiter.new` and `Rule.new` validate names and configuration. In
318
+ `Labkit.dev_or_test?` mode (`RAILS_ENV` set to `development` or `test`), they
319
+ raise `ArgumentError` on:
320
+
321
+ - invalid limiter or rule names
322
+ - duplicate rule names within a single `Limiter`
323
+ - unknown `action` values
324
+ - rule names longer than 64 characters
325
+
326
+ In production, the same conditions are downgraded: invalid names are
327
+ sanitised (and the original/sanitised pair is logged), duplicate rule names
328
+ are dropped (first occurrence wins), and a warning is logged. This keeps a
329
+ misconfiguration from taking the application down at boot.
@@ -14,7 +14,8 @@ module Labkit
14
14
  # period - window in seconds; may be a callable (resolved per check)
15
15
  # action - :block (enforce), :log (count and log only, do not block,
16
16
  # evaluation continues to subsequent rules), or :allow
17
- # (bypass: short-circuit evaluation with no Redis writes)
17
+ # (count but always permit; terminates evaluation on match
18
+ # regardless of whether the limit was exceeded)
18
19
  # characteristics - identifier keys used to build the compound Redis counter key
19
20
  #
20
21
  # +name+ must be a lowercase alphanumeric-and-underscore string of at most 64
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: 1.20.1
4
+ version: 1.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Newdigate
@@ -547,6 +547,7 @@ files:
547
547
  - config/user_experience_slis/testing_sample.yml
548
548
  - doc/FIELD_STANDARDIZATION.md
549
549
  - doc/architecture/decisions/001_field_standardization_dynamic_runtime_linting.md
550
+ - docker-compose.yml
550
551
  - exe/labkit-logging
551
552
  - gitlab-labkit.gemspec
552
553
  - lib/gitlab-labkit.rb
@@ -599,6 +600,7 @@ files:
599
600
  - lib/labkit/middleware/sidekiq/user_experience_sli/server.rb
600
601
  - lib/labkit/net_http_publisher.rb
601
602
  - lib/labkit/rate_limit.rb
603
+ - lib/labkit/rate_limit/README.md
602
604
  - lib/labkit/rate_limit/configuration.rb
603
605
  - lib/labkit/rate_limit/evaluator.rb
604
606
  - lib/labkit/rate_limit/identifier.rb