[Spec 10] labkit-ruby: pattern matching for Rule#match
Parent epic: [gitlab-com/gl-infra#2021](https://gitlab.com/groups/gitlab-com/gl-infra/-/work_items/2021) (Phase 2: Rate Limiting Simplification)
# [Spec 10] labkit-ruby: pattern matching for `Rule#match`
## Spec
### Problem Statement [required]
`Labkit::RateLimit::Rule#match` today accepts a Hash whose values are compared by **equality** against identifier values. This works for static throttle keys (e.g. `{ endpoint: "GET /api/v4/projects" }`) but cannot express path/regex matchers that downstream consumers already rely on. The most concrete example is `Gitlab::RackAttack::Request` in the gitlab-rails monolith, which uses regular expressions to identify protected-path and git-LFS requests ([`lib/gitlab/rack_attack/request.rb#L40-47`](https://gitlab.com/gitlab-org/gitlab/-/blob/0a09d9302efbbcdd1a16359762594c9ea54842d6/lib/gitlab/rack_attack/request.rb#L40-47)) — patterns like `protected_paths_regex` and `git_lfs_path_regex` cannot be expressed by an exact-match Hash.
Without pattern matching in the SDK, callers face two bad options: (a) pre-compute boolean identifier flags at the call site (`protected_path: true`, `git_lfs: true`) — which pushes labkit's matching responsibility onto every caller and bloats the identifier with derived state; or (b) maintain N separate `Limiter`s where one would suffice — which fans out the rule surface and makes operational management harder. This was raised in review of Spec 9 ([#28852](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28852)) as the primary blocker to consolidating the Stage 2b RackAttack-migration Limiter design.
The asymmetry is structural: matching is a labkit concern (it owns rule evaluation), but is currently pushed onto callers. The fix is to extend `Rule#match` to accept regex patterns alongside the existing equality values. **Match semantics use explicit type markers, not the Ruby type of the value:** plain values (`String`, `Symbol`, Integer, etc.) are equality matchers, a single-key Hash `{ eq: <value> }` is the canonical equality matcher, and a single-key Hash `{ re: <String|Regexp> }` is a regex matcher. Ruby callers may also pass a bare `Regexp` object as a convenience; it is normalized to a `:re` matcher internally. The counter-key shape is unchanged — keys still use the identifier value, not the matched pattern.
**Why explicit markers**: rules will eventually be loaded from YAML (see [#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853)). YAML has no native `Regexp` type — a parser cannot distinguish `"^/api/v\d+"` (intended regex) from `"/api/v4/projects"` (intended equality). Type-driven detection (the original sketch of this spec) would have forced YAML callers to invent ad-hoc tags or bake parsing rules into the loader. Explicit markers give Ruby and YAML a single canonical shape, and the SDK normalizes both into the same `Matcher` value object. Hash-key naming follows the metrics-catalog selector pattern (`eq` / `re`) for consistency with infra precedent. Credit: this design follows @reprazent's review on this issue.
**Internal design**: a private `Labkit::RateLimit::Matcher` value object encapsulates `(type, value)` where `type ∈ {:eq, :re}` and `value` is the normalized comparison target (a compiled `Regexp` for `:re`, the original value for `:eq`). `Matcher.build(input)` is the single normalization entry point invoked by `Rule.new` for every value in the `match` Hash. `Matcher#match?(identifier_value)` is the single matching entry point invoked by the evaluator; for `:re` matchers it coerces the identifier value via `#to_s` before applying the regex (see Scenario G). `Rule` and the evaluator know nothing about pattern syntax; that knowledge is contained in `Matcher`.
### Non-Goals [required]
- **Full PCRE feature parity** — plain Ruby `Regexp` is sufficient. Lookahead/lookbehind, backrefs, named captures, and flags beyond the standard set are not exposed beyond what the caller writes into the `re:` source string (or a bare `Regexp` literal).
- **Configurable anchoring** — regex matchers match exactly as the caller wrote them. No global "anchor mode" switch.
- **Cross-key matching** — matchers compare a single identifier value, same as today. A matcher cannot match against a tuple of identifier keys.
- **Counter-key derivation changes** — the compound counter key still uses the *identifier value*, not the pattern. Two requests matching the same regex matcher but with different identifier values produce two different counters. (This is a deliberate constraint: aggregating across pattern-matched identifier values is a future feature, not this one.)
- **Implicit type detection** — the SDK does **not** sniff string contents to reinterpret an equality matcher as anything else; that earlier heuristic was dropped during review (see Problem Statement).
- **Hash-key aliases** — only `:eq` and `:re` are accepted (`{ equality: ... }`, `{ regex: ... }` etc. are rejected as unknown type keys). The terse forms match the metrics-catalog precedent and are cheap to type; an alias is cheap to add later but expensive to keep around forever.
- **Glob matchers** — out of scope for this spec. The earlier sketch proposed both an implicit `*`/`?` glob heuristic and an explicit `{ glob: "<pattern>" }` marker; both are dropped per @reprazent's review. Audit of [`lib/gitlab/rack_attack/request.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/0a09d9302efbbcdd1a16359762594c9ea54842d6/lib/gitlab/rack_attack/request.rb) showed RackAttack uses only `Regexp`, `Regexp.union`, and `Regexp.escape` — regex is sufficient for the Stage 2b migration. Glob is left as a possible follow-up under [#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853) (configuration evolution); callers who want glob-style ergonomics today can express the equivalent regex (e.g. `^/api/v[^/]+/projects$`).
- **Ruby-only typed-object constructor** — no `Matcher.regex("...")`-style helper is offered for Ruby callers. The canonical Hash form (`{ re: "..." }`) and the bare `Regexp` convenience are sufficient; we want config to migrate toward YAML, not toward more Ruby-specific ergonomics.
- **Per-Matcher Regexp timeout** — bounding regex evaluation cost (and the fail-closed-on-timeout policy that should accompany it) is a cross-cutting concern that affects all callers, not just `Rule#match`. Tracked separately, see Bob's follow-up issue linked from [the round-2 review thread](https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/merge_requests/283#note_3320102519).
- **labkit-go** — out of scope for this iteration. The YAML shape is designed to be cross-language (see [#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853)); each language compiles `re:` source strings with its own engine.
- **Identifier-level pattern matching** — matchers appear in `Rule#match`, not in `Identifier`. The identifier remains a flat value object.
- **YAML loading** — owned by [#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853). This spec only ensures the in-memory shape is YAML-compatible.
### Acceptance Criteria [required]
**Scenario A: Equality semantics unchanged (regression)**
- Given: a Rule with `match: { endpoint: "GET /api/v4/projects" }` and identifier `{ endpoint: "GET /api/v4/projects" }`
- When: `limiter.check(identifier)`
- Then: the rule matches; `result.matched?` is `true`; the matched rule is returned. Behaviour is identical to current `main`. The match value is normalized internally to a `Matcher` of type `:eq` with no observable side-effects on the public API. `{ endpoint: { eq: "GET /api/v4/projects" } }` produces an equal Matcher and the same outcome.
**Scenario B: Explicit `{ re: "..." }` (canonical) and bare `Regexp` (Ruby convenience) both match via `Regexp#match?`**
- Given: two equivalent Rules, one with `match: { path: { re: "^/api/v\d+/projects" } }` and one with `match: { path: %r{^/api/v\d+/projects} }`, and identifier `{ path: "/api/v4/projects/42" }`
- When: `limiter.check(identifier)` is called against each
- Then: both rules match; `result.matched?` is `true` for each; both `match` Hashes have been normalized to a `Matcher` of type `:re` with the same compiled `Regexp` value. A third equivalent form, `{ path: { re: %r{^/api/v\d+/projects} } }` (a bare `Regexp` nested inside the marker hash), matches identically.
**Scenario C: No-match leaves the rule un-matched**
- Given: a Rule with `match: { path: { re: "^/admin" } }` and identifier `{ path: "/api/v4/projects" }`
- When: `limiter.check(identifier)`
- Then: the rule does not match this identifier; first-match-wins evaluation continues to the next rule.
**Scenario D: Multiple `match` keys are AND-ed across mixed equality + regex matchers**
- Given: a Rule with `match: { request_type: "api", path: { re: "^/api/v\d+/projects" } }` and identifier `{ request_type: "api", path: "/api/v4/projects" }`
- When: `limiter.check(identifier)`
- Then: both predicates pass; the rule matches. If only one predicate passes, the rule does not match.
**Scenario E: Counter key uses the identifier value, not the pattern**
- Given: a Rule with `match: { path: { re: "^/api/v\d+/projects" } }`, `characteristics: [:user, :path]`, name `"projects_api"`, identifier `{ user: 42, path: "/api/v4/projects" }`
- When: `limiter.check(identifier)`
- Then: Redis receives one `incr` for the compound key `labkit:rl:<limiter_name>:projects_api:user:42:path:/api/v4/projects` — the **identifier value** appears in the key, **not** the regex source.
**Scenario F: Regex compilation errors are raised at `Rule.new`, not at `check`**
- Given: a regex matcher `match: { path: { re: "[unclosed" } }` (invalid `Regexp` source) **or** `match: { path: { re: 42 } }` (a source `Regexp.new` cannot accept)
- When: `Rule.new(...)` is called
- Then: an `ArgumentError` is raised at construction time with a message naming the offending key and the offending source. Both `Regexp::RegexpError` (invalid pattern syntax) and `TypeError` (e.g. Integer or `nil` source) raised by `Regexp.new` are wrapped as `ArgumentError`. `limiter.check` is never reached.
**Scenario G: Non-String identifier value with a regex matcher — coerce via `#to_s`**
- Given: a Rule with `match: { status: { re: "^5\d\d$" } }` and identifier `{ status: 503 }` (Integer, not String)
- When: `limiter.check(identifier)`
- Then: the matcher coerces the identifier value via `#to_s` (`503 → "503"`) and applies the regex to the result; the rule matches. The same behaviour applies to Symbol identifier values (`:api → "api"`) and `nil` (`nil → ""`, which only matches an empty-string regex). There is no environment-dependent branch and no exception or warning. The motivating use case is matching ranges of HTTP status codes (`^5\d\d$`, `^4(?:0[14])$`, etc.) that callers naturally produce as Integers.
**Scenario H: Unsupported `match` value shape fails at `Rule.new`**
- Given: any of the following match values:
- an Array, e.g. `match: { path: ["/a", "/b"] }`
- a Hash with an unknown type key, e.g. `match: { path: { glob: "/a*" } }`, `match: { path: { prefix: "/a" } }`, `match: { path: { equality: "/a" } }`, or `match: { path: { regex: "^/a" } }`
- a Hash with multiple type keys, e.g. `match: { path: { re: "^/a", other: "/a" } }`
- When: `Rule.new(...)` is called
- Then: an `ArgumentError` is raised at construction time naming the offending key, the disallowed shape, and the set of accepted shapes (plain value, `Regexp`, `{ eq: ... }`, `{ re: ... }`).
**Scenario I: Cross-format parity (Ruby ↔ YAML in-memory shape)**
- Given: two `Rule` constructions with the in-memory `match` Hash equal to:
```ruby
# Authored in Ruby
{ request_type: "api", path: { re: '^/api/v\d+/projects' } }
```
and
```ruby
# Loaded from YAML and handed to Rule.new (loader is out of scope; see #28853)
YAML.safe_load(<<~'YAML', symbolize_names: true)
request_type: api
path:
re: '^/api/v\d+/projects'
YAML
```
- When: `Rule.new(match: ...)` is called with each
- Then: both produce a `Rule` whose `match` Hash contains identical `Matcher` instances (same `type`, same compiled `Regexp` source). No format-specific normalization layer is needed in the YAML loader — the shape is identical.
### Security Considerations [required]
- **ReDoS (regular-expression denial of service)**: regex matcher source strings (and bare `Regexp` objects) are **caller-provided**, not user-input. Callers (gitlab-rails, future external services) must not pass user-controlled regex sources into `Rule#match`. Documentation must state this explicitly. A per-Matcher `Regexp.timeout` plus a fail-closed-on-timeout policy is tracked separately (see Non-Goals); for now ReDoS protection rides on caller hygiene.
- **No new bypass surface**: pattern matching narrows which rules apply, never widens. A matcher that matches more identifiers means *more* counter increments, not fewer; it cannot be used to skip enforcement.
- **No secrets in patterns**: same constraint as identifiers — regex sources appear in error messages (Scenario F), in rule names referenced by logs, and may appear in `inspect` output. Documentation must reiterate that secrets must never appear in regex sources or identifiers.
- **Bounded pattern length**: regex source strings are capped at a fixed maximum length (e.g. 200 chars) at `Rule.new` time, applied via `source.to_s.length` so a `Regexp` source is bounded too (its `#to_s` includes a small `(?-mix:…)` wrapper, so the effective allowed source length for a `Regexp` input is a few characters less than the cap — acceptable for engineer-typed sources).
- **No injection from identifiers**: identifier values are matched against compiled `Regexp` instances via `Regexp#match?` after `#to_s` coercion; they never become part of pattern compilation. There is no path by which an identifier value can construct or modify a matcher.
### Rollout & Backwards Compatibility [required]
**Public API surface:**
- `Labkit::RateLimit::Rule#match` accepts a Hash whose values are:
- any plain value (e.g. `String`, `Symbol`, Integer) — treated as an equality matcher; semantics **unchanged** from current `main`.
- a `{ eq: <value> }` single-key Hash — canonical equality matcher; identical semantics to passing the value directly.
- a `{ re: <String|Regexp> }` single-key Hash — regex matcher; the source is compiled with `Regexp.new` at `Rule.new` time. A bare `Regexp` inside the marker hash is accepted and stored directly.
- a bare `Regexp` instance — Ruby-only convenience; normalized internally to a `:re` matcher.
- New: `ArgumentError` at `Rule.new` for unsupported `match` value shapes (Scenario H) and for invalid regex sources (Scenario F).
- The earlier sketch of this spec proposed both an implicit `*`/`?`-in-`String` glob heuristic and an explicit `{ glob: "..." }` matcher. Both are **not** implemented; glob is out of scope for this spec (see Non-Goals).
- No change to `Limiter`, `Identifier`, `Result`, `Evaluator` public API, or counter-key derivation. The `Matcher` value object is internal to the `Labkit::RateLimit` namespace and not part of the public API.
**Versioning:**
- Semver minor bump for labkit-ruby (additive feature, no breaking change — equality semantics for plain `String`/`Symbol` values are preserved).
- gitlab-rails (and any other consumer) pins to the new minimum version when adopting regex matching.
**Self-managed / Dedicated / Cells:** no impact. Library change only; no admin surface, no application-setting change, no schema change.
**Migration plan:**
- No migration required for existing exact-match callers — their behaviour is unchanged.
- Spec 9 ([#28852](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28852), Stage 2b RackAttack migration) is the first consumer; it depends on this spec shipping first. RackAttack uses only `Regexp` / `Regexp.union` / `Regexp.escape`, all of which the regex matcher supports.
- YAML loader ([#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853)) consumes the in-memory shape unchanged — no translation layer needed (Scenario I).
**Rollback:**
- Library-level rollback is a gem-version pin. Consumers that have not yet adopted regex matching see no behaviour change.
### Validation Loop / Verification Process [required]
**Local commands that must pass before requesting human review:**
```bash
bundle exec rspec spec/labkit/rate_limit/matcher_spec.rb \
spec/labkit/rate_limit/rule_spec.rb \
spec/labkit/rate_limit/limiter_spec.rb \
spec/labkit/rate_limit/evaluator_spec.rb \
--format documentation
```
Expected: all scenarios A–I covered, 0 failures, equality-semantics regression specs (Scenario A) pass unchanged.
**Key assertions:**
- `Matcher.build` normalization: every accepted input shape produces a `Matcher` with the expected `(type, value)` pair; every rejected shape (Scenario H) raises `ArgumentError` with a message that names both the offending key and the accepted shapes.
- Regex compilation happens **once** at `Rule.new` (assert via a recording double on `Regexp.new` or by counting calls).
- Counter-key assertion (Scenario E) verifies the identifier value, not the regex source, appears in the key.
- Cast-and-match behaviour (Scenario G) covered with Integer, Symbol, and `nil` identifier values against a `:re` matcher; assert no exception, no log, and that the regex is applied to `identifier_value.to_s`.
- Cross-format parity (Scenario I) asserted by constructing two `Rule`s — one from a Ruby Hash, one from a `YAML.safe_load`-produced Hash — and comparing their `match` Hashes for `Matcher`-equality.
**Evidence to post to MR:** CI pipeline result + `--format documentation` spec output. No production-side validation required for this spec — its consumers (Spec 9) will validate the integrated behaviour at their own rollout time.
### Observability [optional]
No new metrics or logs introduced by this spec. The existing `gitlab_labkit_rate_limiter_calls_total{rate_limiter, rule, action}` Prometheus counter (added by Spec 1e / [#28798](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28798)) continues to label by rule name; the rule name remains the operator-visible identity regardless of whether the rule's match values are equality or regex matchers.
The earlier draft of this spec emitted a structured warning when a non-`String` identifier value met a pattern matcher in production. That warning was removed during review: the per-request hot path should not branch on environment, and a misconfigured caller is better caught by the static config validator that will land with [#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853) than by a runtime log. Under the current cast-via-`#to_s` semantics (Scenario G) a non-`String` identifier no longer "silently fails to match" anyway — it's coerced to a String and matched normally, removing the original motivation for the warning.
## References
- Epic: [gitlab-com/gl-infra#2021](https://gitlab.com/groups/gitlab-com/gl-infra/-/work_items/2021)
- Spec 8 (Limiter API redesign — establishes current `Rule` shape): [#28792](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28792), [labkit-ruby!276](https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/merge_requests/276)
- Spec 9 (Stage 2b RackAttack migration — primary consumer): [#28852](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28852)
- Configuration evolution / YAML loader (downstream consumer of this in-memory shape; future home for any glob follow-up): [#28853](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853)
- Implementation MR: [labkit-ruby!283](https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/merge_requests/283)
- RackAttack regex matchers (motivating example): [`lib/gitlab/rack_attack/request.rb#L40-47`](https://gitlab.com/gitlab-org/gitlab/-/blob/0a09d9302efbbcdd1a16359762594c9ea54842d6/lib/gitlab/rack_attack/request.rb#L40-47)
- Review thread that drove the explicit-marker design and the drop-glob decision: @reprazent's [YAML compatibility concern](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28855#note_3315354927) (2026-05-05) and [follow-up clarification](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28855#note_3317039401) confirming glob is out of scope.
- Round-2 review on !283 ([@reprazent, 2026-05-06](https://gitlab.com/gitlab-org/ruby/gems/labkit-ruby/-/merge_requests/283#note_3320102540)): tightened `KNOWN_HASH_KEYS` to `%i[eq re]` (no `:equality` alias), applied length cap to both String and Regexp sources via `source.to_s.length`, and switched `match?` for `:re` to coerce the identifier value via `#to_s` (Scenario G) so callers can match patterns against Integer/Symbol identifier values (status codes, etc.).
issue
GitLab AI Context
Project: gitlab-com/gl-infra/production-engineering
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/raw/main/README.md — project overview and setup
Repository: https://gitlab.com/gitlab-com/gl-infra/production-engineering
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD