Add rule_extras: parameter to Limiter#check and Limiter#peek for callable context
Add an optional `rule_extras:` keyword argument to `Limiter#check` and `Limiter#peek` that is passed to one-arity callables on `limit` and `period`. This lets callers provide preloaded context (namespace settings, request-scoped data) without polluting the identifier or causing out-of-band database queries. Parent epic: https://gitlab.com/groups/gitlab-com/gl-infra/-/work_items/2021 Related: https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28853 (configuration evolution) Related: https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/29054 (static LIMITERS hash) Related: https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/work_items/28811 (IncrementPerActionedResource migration — needs per-namespace settings) ## Problem Some rate limits have configuration that depends on the request context. The `unique_project_download_limit` is set per-namespace by group owners (Ultimate feature), not globally in ApplicationSettings. The current adapter rebuilds the `Rule` per-request to resolve these values, which defeats the purpose of static `Limiter` objects (#29054). Zero-arity callables (`-> { ApplicationSetting.current.some_limit }`) work for global settings. But per-namespace settings need access to request context that the callable doesn't have at construction time. Passing the identifier to callables is wrong — the identifier is for matching and counting, not for configuration resolution. And having the callable do its own database query (e.g., `Namespace.find(identifier[:namespace_id])`) causes out-of-band queries on every rate limit check. ## Proposal Add a `rule_extras:` keyword argument to `check` and `peek`. The caller resolves configuration values and passes them as primitives: ```ruby result = limiter.check( { user_id: user.id, namespace_id: namespace.id, project_id: project.id }, rule_extras: { limit: namespace.namespace_settings.unique_project_download_limit, period: namespace.namespace_settings.unique_project_download_limit_interval_in_seconds } ) ``` Rules use one-arity callables that read from `rule_extras`: ```ruby Rule.new( name: "unique_project_downloads_for_namespace", characteristics: [:user_id, :namespace_id], count_distinct: :project_id, limit: ->(rule_extras) { rule_extras[:limit] || 0 }, period: ->(rule_extras) { rule_extras[:period] || 600 }, action: :limit ) ``` The rule doesn't need to know where the values came from. The caller resolves them and passes the primitives. This keeps the rule definition generic and reusable. ### Separation of concerns | Argument | What it's for | Used by | |---|---|---| | `identifier` | Request context for matching and counting | `rule.match`, `rule.characteristics`, Redis key | | `rule_extras:` | Preloaded configuration context | One-arity callables on `limit` and `period` | | `cost:` | How much to count | Redis increment value | The identifier stays pure. `rule_extras` carries things the caller already has in hand that callables need to resolve configuration. ### Implementation In the evaluator, `resolve_value` gains an optional `rule_extras` argument: ```ruby def resolve_value(val, rule_extras = nil) if val.respond_to?(:call) val.arity == 0 ? val.call : val.call(rule_extras) else val end end ``` Zero-arity callables keep working as-is. One-arity callables receive the `rule_extras` hash. `Limiter#check` and `Limiter#peek` gain the keyword: ```ruby def check(identifier, cost: 1, rule_extras: nil) def peek(identifier, rule_extras: nil) ``` Backwards compatible — existing callers that don't pass `rule_extras` are unaffected. ### What this enables Static `Limiter` objects (#29054) with per-namespace configuration: ```ruby LIMITERS = { unique_project_downloads_for_namespace: Labkit::RateLimit::Limiter.new( name: "unique_project_downloads", rules: [ Labkit::RateLimit::Rule.new( name: "unique_project_downloads_for_namespace", characteristics: [:user_id, :namespace_id], count_distinct: :project_id, limit: ->(rule_extras) { rule_extras[:limit] || 0 }, period: ->(rule_extras) { rule_extras[:period] || 600 }, action: :limit ) ] ) }.freeze ``` The call site resolves the namespace settings and passes them in: ```ruby ns = namespace.namespace_settings result = LIMITERS[:unique_project_downloads_for_namespace].check( { user_id: user.id, namespace_id: namespace.id, project_id: project.id }, rule_extras: { limit: ns.unique_project_download_limit, period: ns.unique_project_download_limit_interval_in_seconds } ) ``` ### What this does NOT affect - YAML config rules (Phase 2) — static values, no callables, no rule_extras needed - External service rules (Phase 3) — same, no callables - Metrics — rule_extras are not logged or labeled - The identifier — unchanged, not used for config resolution ## Acceptance criteria - `Limiter#check` accepts optional `rule_extras:` keyword - `Limiter#peek` accepts optional `rule_extras:` keyword - Zero-arity callables on `limit`/`period` keep working without `rule_extras` - One-arity callables receive the `rule_extras` hash - `rule_extras: nil` (default) passed to one-arity callable as `nil` — callable must handle this - Existing tests pass without changes (backwards compatible) - New tests cover: one-arity callable with rule_extras, one-arity callable with nil rule_extras, zero-arity callable unaffected
issue