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.