[Proposal] Add ban_for: and extend_ban: options to rate_limit for blocking longer than the detection window

Context

With rate_limit, how long a client is blocked is the same as the window you measure requests in (within:) — you can’t block for longer than that. Looking at the implementation (rate_limiting.rb#L72-L90):

def rate_limiting(to:, within:, by:, with:, store:, name:, scope:)
  by = by.is_a?(Symbol) ? send(by) : instance_exec(&by)
  cache_key = ["rate-limit", scope, name, by].compact.join(":")
  count = store.increment(cache_key, 1, expires_in: within)
  if count && count > to
    # ... refuse the request
  end
end

The counter key expires after within:, so a client that trips the limit is refused only until that same window elapses. There’s no way to express a very common policy:

Allow 5 requests per 10 seconds. If a client exceeds that, block them for an hour.

To get this today you reach for rack-attack (Fail2Ban / Allow2Ban, which have a dedicated bantime) or hand-roll a second cache key. It feels like something rate_limit should be able to express directly.

Proposal

Add an optional ban duration, decoupled from within::

# allow 5 / 10s; if exceeded, refuse all requests from this client for 1 hour
rate_limit to: 5, within: 10.seconds, ban_for: 1.hour, only: :create

And an optional toggle for whether requests during the ban reset the timer:

# fixed: the ban ends 1h after it is first triggered (default)
rate_limit to: 5, within: 10.seconds, ban_for: 1.hour

# sliding: each request during the ban restarts the 1h window
rate_limit to: 5, within: 10.seconds, ban_for: 1.hour, extend_ban: true

# sliding without ban_for: defaults to `within`, so each request over the limit
# restarts the 10s window — the client stays blocked until it pauses for 10s
rate_limit to: 5, within: 10.seconds, extend_ban: true

When ban_for: is omitted, behavior is identical to today — fully backward compatible.

Implementation note

The ban duration can be applied with store.write. The core of a reference implementation:

count = store.increment(cache_key, 1, expires_in: within) # original behavior
return unless count && count > to

# new: default the ban duration to the detection window
ban_for ||= within

# new: apply the ban — extend_ban re-applies on every exceeded request,
# otherwise applied once, when the limit is first exceeded
store.write(cache_key, count, expires_in: ban_for) if extend_ban || (ban_for != within && count == to + 1)

# ... existing instrument + `with` handling unchanged

With defaults (ban_for:within:, extend_ban: false) no extra write happens and behavior is identical to current rate_limit.

I’d be happy to open a PR if this sounds useful. Feedback welcome.