Refine rule action semantics: limit, log, skip

Refine rule action semantics: limit, log, skip

Evolve the rule action model to cleanly separate what a rule does from what the caller should do. This enables multiple independent limits, shadow-testing, and bypasses with clear semantics.

Context: Slack discussion 2026-04-30 between Bob, Andrew, Max, Donna. Inspiration: https://developers.cloudflare.com/ruleset-engine/rules-language/actions/#supported-actions

Current state

Rule actions: :block, :log, :allow

  • :block — terminating, count and block on exceeded
  • :log — non-terminating, count but never block (#28890 (closed))
  • :allow — terminating, count but always allow (bypasses)

Problem

:allow is overloaded — it means both "bypass, stop evaluating" (terminating) and we want it to mean "this limit passed, check the next one" (non-terminating). These are fundamentally different.

Proposed model

Three rule actions that describe what the rule does:

Rule action What it does
limit Count against the limit. The primary enforcement action.
log Count against the limit. Observability only, never blocks.
skip Don't count. Bypass — explicitly allow and stop evaluation.

The result action describes what the caller should do, derived from the rule action and exceeded state:

Rule action Exceeded Result action Terminating
limit no allow no
limit yes block yes
log no allow no
log yes allow no
skip N/A allow yes

What this enables

Multiple independent limits (all must pass)

rules: [
  { name: "org_pipeline_limit", action: :limit,
    characteristics: [:namespace], limit: 100, period: 60 },
  { name: "user_pipeline_limit", action: :limit,
    characteristics: [:user], limit: 10, period: 60 }
]

Both :limit rules are evaluated. If the org limit passes (within limits), evaluation continues to the user limit. If the user limit also passes, the request is allowed. If either is exceeded, evaluation stops and the request is blocked.

Shadow-testing new thresholds

rules: [
  { name: "lower_api_threshold", action: :log,
    characteristics: [:user], limit: 5, period: 60 },
  { name: "authenticated_api", action: :limit,
    characteristics: [:user], limit: 20, period: 60 }
]

The :log rule counts and emits metrics showing how many requests would be caught at the lower threshold. The :limit rule continues to enforce the real threshold. Operators can see the shadow impact without disrupting enforcement.

Bypasses

rules: [
  { name: "bypass_header", match: { bypass: true }, action: :skip },
  { name: "authenticated_api", action: :limit, ... }
]

The :skip rule matches bypass-header requests, doesn't count, and terminates evaluation. No subsequent rule is evaluated.

Evaluation logic

def check_rules(identifier)
  @rules.each do |rule|
    next unless rule_matches?(rule, identifier)

    if rule.action == :skip
      result = Result.new(matched: true, action: :allow, rule: rule)
      report_matched_metrics(result)
      return result
    end

    result = evaluate_rule(rule, identifier)
    report_matched_metrics(result)

    # :limit terminates on exceeded (block), continues on allow
    # :log never terminates
    return result if result.action == :block
  end

  report_unmatched_metrics
  Result.new(matched: false, action: :allow)
end

And in evaluate_rule, the result action is derived:

exceeded = count > resolved_limit
action = case rule.action
         when :limit then exceeded ? :block : :allow
         when :log then :allow
         end

Result object

The result carries:

  • action:allow or :block (what the caller should do)
  • exceeded? — whether the count exceeded the limit (true even for :log rules, for observability)
  • rule — the last evaluated rule. For terminating cases (:skip match, :limit exceeded), this is the rule that caused termination. For non-terminating cases (all rules passed), this is the last rule in the evaluation chain.

For :log rules that are exceeded: result.action == :allow but result.exceeded? == true. The metrics capture this distinction.

Note: a future improvement could return the most restrictive :limit rule (lowest remaining) instead of the last evaluated one. This would be more useful for response headers (RateLimit-Remaining). Deferred for now in favor of simplicity.

Migration from current actions

Current New Behavior change
:block :limit None — same semantics (terminate on exceeded)
:log :log None — same semantics (non-terminating)
:allow :skip None — same semantics (terminate, bypass)

Options for migration:

  1. Add :limit and :skip as new actions, deprecate :block and :allow with aliases during transition
  2. Rename in one release (breaking change, but no external consumers yet beyond gitlab-rails Stage 2a adapter)

Metrics considerations

With the refined action model and non-terminating rules, a single metric can no longer capture both the per-request outcome and the per-rule behavior. We should split into two metrics:

Metric 1: Per-limiter check (one increment per check call)

gitlab_labkit_rate_limiter_calls_total{rate_limiter, result}
  • rate_limiter — the limiter name (e.g., rack_request)
  • resultallow or block (the final outcome for the caller)

Tells you: "how many requests were checked, and how many were blocked vs allowed?" Low cardinality, good for dashboards showing overall rate limiting health.

Metric 2: Per-rule evaluation (one increment per rule evaluated)

gitlab_labkit_rate_limiter_rule_evaluations_total{rate_limiter, rule, action, result, exceeded}
  • rate_limiter — which limiter this rule belongs to
  • rule — the rule name
  • action — the rule's configured action (limit, log, skip)
  • result — the rule's result (allow or block)
  • exceededtrue or false

Tells you everything about individual rule behavior:

  • Shadow impact: rate(...{action="log", exceeded="true"}[5m])
  • Bypass rate: rate(...{action="skip"}[5m])
  • Per-rule enforcement blocks: rate(...{action="limit", result="block"}[5m])
  • Enforcement rules passing: rate(...{action="limit", exceeded="false"}[5m])

With non-terminating rules, a single check call can evaluate multiple rules — the per-rule metric captures all of them.

Existing metrics renamed

Current New
gitlab_labkit_rate_limiter_calls_total{rate_limiter, rule, action} Split into calls_total{rate_limiter, result} + rule_evaluations_total{rate_limiter, rule, action, result, exceeded}
gitlab_labkit_rate_limiter_errors_total{rate_limiter} Unchanged
gitlab_labkit_rate_limiter_limit{rate_limiter, rule} Unchanged
gitlab_labkit_rate_limiter_period_seconds{rate_limiter, rule} Unchanged

Breaking change

This is a breaking change to labkit. The action rename (blocklimit, allowskip), the metric rename, and the new evaluation semantics (non-terminating limit rules) must ship together in a single labkit release with a major or minor version bump. All callers (gitlab-rails Stage 2a adapter, future Stage 2b middleware) must update:

  • Rule construction: action: :blockaction: :limit, action: :allowaction: :skip
  • Metric queries: update dashboards and alerts to use the new metric names and labels
  • Result handling: result.action values are unchanged (allow/ block), but callers checking rule.action need updating

Not needed for current migrations

This refinement is not required for Stage 2a (ApplicationRateLimiter) or Stage 2b (RackAttack) migrations. The current :block/:log/ :allow model is sufficient. This is needed when we push more responsibility into rule configuration and enable operators to define multiple independent limits for the same rate limiter.

When this change ships, the Stage 2a adapter (gitlab!233816) and any dashboard queries (#28831 (closed)) referencing the current metric names must be updated in the same release cycle.

  • #28890 (closed) — Fix: :log rules early-return (prerequisite, merged)
  • #28853 — Configuration evolution (this enables Phase 2 precedence)
  • #28852 — Stage 2b RackAttack migration (future consumer)