Labkit web throttles split one Rack::Attack counter across two Redis keys
## What's wrong
Rack::Attack keeps **one tally per person, per throttle**. Every web request that person makes goes on that one tally, and once it passes the limit they get blocked.
Our labkit version of the web throttles keeps **two tallies** instead of one.
That happened for an understandable reason. "Is this a web request?" actually means "is it a web page **or** is it a browser API call (one carrying a CSRF token)". A labkit rule can only test conditions joined by AND, never OR, so we wrote two rules instead — one matching web pages, one matching browser API calls. Labkit names each Redis counter after the rule that wrote to it, so two rules gave us two counters.
The effect is that one person's traffic gets divided between two tallies, and neither one fills up.
A worked example. Say the limit is 100 requests a minute, and someone makes 120 — 60 page views and 60 background API calls from their browser:
| | Rack::Attack | labkit |
|---|---|---|
| Page views | 120 on one tally | 60 on tally A |
| Browser API calls | (same tally) | 60 on tally B |
| Over 100? | **Yes, blocked** | No, and no. **Allowed** |
Same person, same traffic, same limit, opposite outcome — purely because we're counting it in two places.
## How lopsided is the split in practice?
Close to 50/50, which is roughly the worst case. Measured on gprd over 6 hours, after !248304 was deployed:
| labkit rule | requests/s | blocks/s |
|---|---|---|
| `authenticated_web` | 3402.7 | 0.00593 |
| `authenticated_web_frontend` | 2926.8 | 0.00023 |
| `unauthenticated_web` | 1670.2 | 3.584 |
| `unauthenticated_web_frontend` | 659.2 | 0.0055 |
**46% of signed-in web traffic is going to the second tally.** That figure is steady (47% over 1 hour, 46% over 6). At a near-even split, a person has to send close to double the limit before either tally trips.
The blocks column shows the same story from the other side: the second tally handles 86% as much traffic as the first but produces 4% as many blocks, because it never fills up.
Comparing total blocks against Rack::Attack, labkit comes out below the 0.95 floor of our per-throttle gate in every window we looked at — somewhere in the range 0.65-0.93 for `unauthenticated_web` and 0.15-0.33 for `authenticated_web`, over 24h and 6h. Those are low-volume series and the two windows move in opposite directions, so please read that as "consistently too low" rather than a precise figure. The 46% traffic split is the number to trust: it doesn't involve comparing against Rack::Attack at all, so the clock-alignment noise that muddies the block comparisons can't affect it.
Web is the only family of throttles where labkit blocks *less* than Rack::Attack. Everywhere else it blocks 15-40% more.
## Why not just accept it?
Because this isn't a reporting discrepancy that goes away when we stop looking at the dashboard. When we switch cohort 2 to enforce, labkit becomes the thing that decides. Traffic that Rack::Attack blocks today would simply stop being blocked. That's a real change in behaviour at cutover, not a difference in how we measure.
One thing worth heading off: the overall shadow divergence number does **not** show this problem, and shouldn't be used to argue it away. That number is dominated by the API throttles, where the two systems use clock windows that start at different moments — they disagree constantly no matter how correct both are. That's the reason we replaced it with the per-throttle block-volume comparison as the gate.
## The fix
!246108 (currently draft).
Work out "web page OR browser API call" **once**, before the rules run, and hand labkit the answer as a single true/false fact. Then each web throttle is one rule with one counter, which is what Rack::Attack had all along.
There's one extra piece. The general API rules currently avoid claiming browser API calls only because the web rules sit above them in the list — but that ordering only holds while the web throttle is switched on. Rack::Attack's API rules exclude browser API calls unconditionally. So the API rules also get an explicit "not a browser API call" condition, which matches what Rack::Attack does whatever the settings say.
## What this does not fix
There's a second, separate gap: some requests should be counted by *two different* throttles at once. A collector request is also a web request, and a browser API call to a specialized API path is also web traffic — Rack::Attack counts those under both throttles, and we only count them under the first one that matches.
That one is a known and accepted trade-off for Stage 2b, and there are tests pinning the current behaviour so it can't drift silently. Closing it would mean letting both throttles count, which in turn needs a rule for deciding which throttle's 429 the user actually sees. Separate piece of work. Related: #29363.
## Background
This is the follow-up to #29362, which was closed as Complete on 2026-07-31 while this particular fix was still unlanded.
issue
GitLab AI Context
Project: gitlab-com/gl-infra/production-engineering
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/raw/main/README.md — project overview and setup
Repository: https://gitlab.com/gitlab-com/gl-infra/production-engineering
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD