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