feat(rate_limit): rule_context for per-request callable context
What this MR does
Adds an optional rule_context: keyword argument to Limiter#check and Limiter#peek (also threaded through the Labkit::RateLimit.check convenience wrapper). The hash is passed to one-arity callables on Rule#limit / Rule#period via arity-dispatched resolution in the Evaluator.
def resolve_value(val, rule_context = nil)
return val unless val.respond_to?(:call)
val.respond_to?(:arity) && val.arity >= 1 ? val.call(rule_context) : val.call
endBehavior:
- Zero-arity callables (
-> { ApplicationSetting.current.foo }) — unchanged, called with no args. - Arity >= 1 callables (
->(ctx) { ctx&.dig(:limit) || 0 }) — receiverule_context(may benilif caller didn't pass it). - Variadic callables (
->(*args) { ... },arity == -1) — take the zero-arg path. Opt intorule_contextby writing the lambda with exactly one required parameter (->(ctx) { ... }). This avoids the footgun where->(*args)silently receives[rule_context]and the caller's overrides never reach the rule's lookup. - Callables without
#arity(custom class withdef calland no explicitarity) — take the zero-arg path, preserving the original API contract. - Plain values (Integer, etc.) — unchanged.
- Existing tests pass without modification.
Why
This is the second of two MRs needed for cohort 4 of the labkit rate-limit rollout. The first is !292 (merged) (count_distinct: at the rule level — already in review). Cohort 4 brings in unique_project_downloads_for_namespace and friends, whose limit:/period: are configured per-namespace (group owners set them via the GitLab UI).
A zero-arity callable can't reach the right namespace — it doesn't have request context. The three escape hatches without rule_context: are all bad:
- Rebuild the
Ruleper-request → defeats the staticLIMITERS = { ... }.freezedirection (#29054). - DB-query inside the callable → out-of-band query on every rate-limit check.
- Stuff config into the identifier → pollutes the Redis bucket key.
rule_context: adds a separate channel for "stuff the caller has in hand that the rule needs to resolve its limits," keeping the identifier pure and the rule definition static.
Example (cohort 4)
# Rule defined once, statically
LIMITERS[:unique_project_downloads_for_namespace] = Labkit::RateLimit::Limiter.new(
name: "unique_project_downloads_for_namespace",
rules: [
Labkit::RateLimit::Rule.new(
name: "per_user_namespace",
characteristics: [:user_id, :namespace_id],
count_distinct: :project_id, # from !292
limit: ->(ctx) { ctx&.dig(:limit) || 0 }, # new
period: ->(ctx) { ctx&.dig(:period) || 600 }, # new
action: :block,
)
]
)
# Call site resolves the per-namespace settings once and passes them in
ns_settings = namespace.namespace_settings
LIMITERS[:unique_project_downloads_for_namespace].check(
{ user_id: u.id, namespace_id: ns.id, project_id: p.id },
rule_context: {
limit: ns_settings.unique_project_download_limit,
period: ns_settings.unique_project_download_limit_interval_in_seconds,
},
)Tests
Rate-limit suite: 326 examples passing (277 unit + 49 integration). Coverage spans:
- One-arity limit callable receives
rule_context - One-arity period callable receives
rule_context - One-arity callable with omitted
rule_contextreceivesnil - Zero-arity callable unaffected when
rule_contextis provided - Variadic callable (
->(*args) { ... }) takes the zero-arg path — receives[], not[rule_context] - Custom callable object without
#aritytakes the zero-arg path (regression guard for therespond_to?(:arity)change) rule_contextflows throughpeekas well- Limiter-level kwarg passthrough for both
checkandpeek - Real-Redis integration: per-request limit override produces expected
exceeded?/resolved_limit, and period override controls the Redis TTL
Rubocop clean.
Reviewer notes
- Built on top of !292 (merged) (
rate-limit/unique-cardinality). Should land after !292 (merged) merges; the diff currently shows therule_contextchanges on top of !292 (merged)'s commits. Will rebase onto master once !292 (merged) lands. - Backward compatible. No existing call sites need changes. Only rules that opt into one-arity callables care about
rule_context. Callable objects that respond to:callbut not:arityare preserved by therespond_to?(:arity)guard inresolve_value. - One small design choice worth flagging: a variadic callable (
->(*args) { ... }witharity == -1) takes the zero-arg path, not the one-arg path. Reason: if->(*args)silently received[rule_context], the rule'sargs[0][:limit] || defaultlookup would still seenilfor any caller that didn't passrule_context:, and there'd be no error — caller overrides would never reach the rule. Opt in torule_contextwith exactly one required parameter (->(ctx) { ... }). Documented inline inresolve_value. - The branch is still named
rate-limit/rule-extras(kept stable to avoid disturbing the existing MR plumbing); the commit on top of41323baperforms therule_extras→rule_contextrename and adds the arity guard.