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
end

Behavior:

  • Zero-arity callables (-> { ApplicationSetting.current.foo }) — unchanged, called with no args.
  • Arity >= 1 callables (->(ctx) { ctx&.dig(:limit) || 0 }) — receive rule_context (may be nil if caller didn't pass it).
  • Variadic callables (->(*args) { ... }, arity == -1) — take the zero-arg path. Opt into rule_context by 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 with def call and no explicit arity) — 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:

  1. Rebuild the Rule per-request → defeats the static LIMITERS = { ... }.freeze direction (#29054).
  2. DB-query inside the callable → out-of-band query on every rate-limit check.
  3. 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_context receives nil
  • Zero-arity callable unaffected when rule_context is provided
  • Variadic callable (->(*args) { ... }) takes the zero-arg path — receives [], not [rule_context]
  • Custom callable object without #arity takes the zero-arg path (regression guard for the respond_to?(:arity) change)
  • rule_context flows through peek as well
  • Limiter-level kwarg passthrough for both check and peek
  • 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 the rule_context changes 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 :call but not :arity are preserved by the respond_to?(:arity) guard in resolve_value.
  • One small design choice worth flagging: a variadic callable (->(*args) { ... } with arity == -1) takes the zero-arg path, not the one-arg path. Reason: if ->(*args) silently received [rule_context], the rule's args[0][:limit] || default lookup would still see nil for any caller that didn't pass rule_context:, and there'd be no error — caller overrides would never reach the rule. Opt in to rule_context with exactly one required parameter (->(ctx) { ... }). Documented inline in resolve_value.
  • The branch is still named rate-limit/rule-extras (kept stable to avoid disturbing the existing MR plumbing); the commit on top of 41323ba performs the rule_extrasrule_context rename and adds the arity guard.
Edited by Max Woolf

Merge request reports

Loading
Loading