feat(rate_limit): add ban_for and Limiter#clear
What
Adds ban_for to Rule and #clear to Limiter.
A rule carrying ban_for counts as usual, and on crossing its limit writes a second key that keeps blocking after the counting window has expired. While that ban holds the rule stops counting, so a caller cannot extend its own ban. Limiter#clear(identifier) discards a limiter's counters and bans for one identifier.
Implements the semantics documented in handbook!20850, merged today.
Why
Groundwork for gitlab-com/gl-infra/production-engineering#29557, replacing Rack::Attack::Allow2Ban, which guards Git HTTP and container registry authentication. Counting alone cannot express it: a ban has to outlive its window, and a successful login has to discard the state early.
A ban is not an action
action decides who is blocked; ban_for decides the accounting. So the two compose:
action: :limitwithban_forenforces the banaction: :logwithban_fordoes identical accounting, including writing the ban and suppressing counting while it holds, and only skips the blocking
That second line is the point of the shape @reprazent suggested during design review. A shadow measures what enforcement would have produced rather than approximating it, which is how :log already relates to :limit everywhere else.
Redis keys and cluster safety
BAN_SCRIPT is the first script here to touch two keys, and GitLab.com stores this data in a Redis Cluster, which rejects a script whose keys span slots. The limiter, rule and characteristics therefore sit inside a hash tag, with the suffix outside it:
labkit:rl:{git_basic_auth:failed_auth_ban_by_ip:ip:1.2.3.4}
labkit:rl:{git_basic_auth:failed_auth_ban_by_ip:ip:1.2.3.4}:banBoth hash on the identical braced portion, so they always land on the same shard.
This changes the shape of every key, not only ban keys, because the counter has to carry the tag for the ban to share its slot. Two consequences:
- in-flight counters are abandoned on deploy, which is one window of under-counting (60s for the git throttles)
- anything matching
labkit:rl:<limiter>:*needslabkit:rl:{<limiter>:*
Metrics
A blocking ban would otherwise be indistinguishable from an ordinary block, since the action is still limit. rule_evaluations_total now reports result="banned", which is where @reprazent preferred it over widening calls_total, and it fits the action/result split from labkit!343.
Design notes for review
- An active ban short-circuits before the increment, matching Allow2Ban, whose
filterreturns early when banned. - One Lua script does the ban check, the increment and the ban write. Split across calls, a concurrent check can miss the threshold crossing. The ban check reads
TTLalone:>= 0means the key exists and gives the remaining time, so no separateEXISTS. exceededtracks the ban, not the count. Once the window expires the count can sit below the limit while the ban still holds.reset_atis the ban expiry while banned, since that is when a retry is useful.ban_foris rejected on:skip(nothing counted, so no limit to cross) and withcount_distinct(the ban path counts withINCRBYFLOATand never reaches the SADD branch, so the pairing silently counted the wrong thing).clearis scoped to limiter plus identifier, not to a rule: a caller clearing after a success knows who succeeded, not which rules matched. It fails open likecheck.
Testing
bundle exec rake verify: 2002 examples, 0 failures, rubocop clean across 202 files.
Integration specs against real Redis cover the ban landing on crossing the limit, outliving the counter window, not counting while banned, leaving other identifiers alone, peek seeing the ban without writing, and clear removing both keys. The shadow has its own three: it reports the ban without blocking, suppresses counting exactly as enforcement would, and does not terminate evaluation. Both result="banned" cases are pinned.
Related
- handbook!20850, the design doc, merged
- gitlab-com/gl-infra/production-engineering#29557, the migration this unblocks
- labkit!343, whose
action/resultsplit carries the ban visibility