feat(rate_limit): regex matchers in Rule#match (Spec 10)
Summary
Implements Spec 10 — extends Labkit::RateLimit::Rule#match so callers can match identifier values via regex patterns alongside the existing equality semantics. Unblocks Spec 9 / Stage 2b RackAttack migration.
Closes gitlab-com/gl-infra/production-engineering#28855 (closed).
Match-value shape
| Input | Result |
|---|---|
any plain value (String, Symbol, Integer, …) |
:equality matcher (semantics unchanged) |
{ regex: "<source>" } single-key Hash |
:regex matcher (canonical, YAML-compatible) |
bare Regexp instance |
:regex matcher (Ruby-side convenience) |
Everything else raises ArgumentError at Rule.new. In particular { glob: "..." }, multi-key Hashes, and Arrays are explicitly rejected — glob support is intentionally out of scope per @reprazent's review and is deferred to #28853 (config evolution).
Internals
A private Labkit::RateLimit::Matcher value object (Data.define(:type, :value)) encapsulates pattern logic:
Matcher.build(input)is the single normalization entry point invoked byRule.newfor every value in thematchHash.Matcher#match?(identifier_value)is the single matching entry point invoked byEvaluator#rule_matches?.RuleandEvaluatorknow nothing about pattern syntax.
Rule.new runs every match value through Matcher.build; Evaluator#rule_matches? becomes rule.match.all? { |key, matcher| matcher.match?(identifier[key]) }.
Behaviour notes
- Quiet skip on type mismatch: a non-
Stringidentifier value paired with a:regexmatcher returnsfalse. No exception, no log, environment-agnostic. (Spec Scenario G — replaces the earlier env-dependent raise.) - Eager compilation: regex source strings are compiled once at
Rule.new; subsequentchecks reuse the compiledRegexp. Asserted by spec. - Counter-key shape unchanged: keys still use the identifier value, not the pattern source.
- Bounded pattern length: regex source strings are capped at 200 characters at
Rule.new(defence-in-depth against ReDoS / oversized config).
Acceptance criteria covered
All Scenarios A–I from the spec acceptance criteria have at least one corresponding test:
| Scenario | Test location |
|---|---|
| A — equality unchanged | evaluator_spec.rb "Spec 10 Scenario A" + existing rule_spec equality cases |
B — { regex: ... } and bare Regexp |
evaluator_spec.rb "Spec 10 Scenario B"; matcher_spec.rb (canonical + bare) |
| C — non-match falls through | evaluator_spec.rb "Spec 10 Scenario C" |
| D — AND across mixed equality + regex | evaluator_spec.rb "Spec 10 Scenario D" (both-pass / only-equality / only-regex) |
| E — counter key uses identifier value | evaluator_spec.rb "Spec 10 Scenario E" |
F — invalid regex raises at Rule.new |
rule_spec.rb "raises ArgumentError on an invalid regex source"; matcher_spec.rb RegexpError wrap |
| G — non-String identifier quiet-skips | evaluator_spec.rb "Spec 10 Scenario G" (no raise + falls through to next rule); matcher_spec.rb (Integer / nil / Symbol) |
| H — unsupported shapes raise | matcher_spec.rb (Array, empty Hash, multi-key Hash, { glob: ... }, { prefix: ... }); rule_spec.rb mirrored cases |
| I — Ruby |
rule_spec.rb "cross-format parity" using YAML.safe_load |
Plus a perf guarantee spec in rule_spec.rb asserting Regexp.new is invoked exactly once per match value at Rule.new.
Verification evidence
$ bundle exec rspec spec/labkit/rate_limit/ --format progress
.....................................................................................................................................
Finished in 0.17 seconds (files took 0.48 seconds to load)
133 examples, 0 failures$ bundle exec rspec
.[…elided 1671 dots…]
Finished in 6.92 seconds (files took 1.36 seconds to load)
1671 examples, 0 failures$ bundle exec rubocop lib/labkit/rate_limit spec/labkit/rate_limit
14 files inspected, no offenses detectedCI pipeline result will land here once the runner picks the MR up.
Out of scope
- Glob matchers — explicitly rejected at
Rule.new. Future home is #28853. - Ruby-only typed-object constructor (e.g.
Matcher.regex("...")) — declined per the spec's "more config in YAML, not in Ruby" Non-Goal. - YAML loader — owned by #28853. This MR only ensures the in-memory shape is YAML-compatible (Scenario I).
- labkit-go, gitlab-rails call-site migration — separate work items.
Test plan
-
bundle exec rspec spec/labkit/rate_limit/— 133/133 green -
bundle exec rspec(full suite) — 1671/1671 green -
bundle exec rubocop lib/labkit/rate_limit spec/labkit/rate_limit— clean - CI pipeline green
- Adversarial review (per agent workflow)
- Human review by a labkit-ruby maintainer