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)
endAnd 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
endResult object
The result carries:
action—:allowor:block(what the caller should do)exceeded?— whether the count exceeded the limit (true even for:logrules, for observability)rule— the last evaluated rule. For terminating cases (:skipmatch,:limitexceeded), 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:
- Add
:limitand:skipas new actions, deprecate:blockand:allowwith aliases during transition - 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)result—alloworblock(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 torule— the rule nameaction— the rule's configured action (limit,log,skip)result— the rule's result (alloworblock)exceeded—trueorfalse
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 (block →
limit, allow → skip), 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: :block→action: :limit,action: :allow→action: :skip - Metric queries: update dashboards and alerts to use the new metric names and labels
- Result handling:
result.actionvalues are unchanged (allow/block), but callers checkingrule.actionneed 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.
Related issues
- #28890 (closed) — Fix:
:logrules early-return (prerequisite, merged) - #28853 — Configuration evolution (this enables Phase 2 precedence)
- #28852 — Stage 2b RackAttack migration (future consumer)