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 by Rule.new for every value in the match Hash.
  • Matcher#match?(identifier_value) is the single matching entry point invoked by Evaluator#rule_matches?.
  • Rule and Evaluator know 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-String identifier value paired with a :regex matcher returns false. 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; subsequent checks reuse the compiled Regexp. 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 ↔️ YAML in-memory parity 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 detected

CI 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

🤖 Generated with Claude Code

Edited by Sam Wiskow

Merge request reports

Loading
Loading