Skip to content

Add Fail2Ban.retry_after to read the remaining ban time - #723

Open
ralder wants to merge 1 commit into
rack:mainfrom
ralder:fail2ban-retry-after
Open

ralder wants to merge 1 commit into
rack:mainfrom
ralder:fail2ban-retry-after

Conversation

@ralder

@ralder ralder commented Oct 20, 2026

Copy link
Copy Markdown

Problem

There is no way to know when a Fail2Ban/Allow2Ban ban ends. An API that wants to send an accurate Retry-After header for a long ban (e.g. Allow2Ban with bantime: 1.hour) can only send the constant bantime. A client that is told this late in the ban waits much longer than it needs to.

#170 proposed banned_until based on the cache TTL. It was closed because memcached cannot read a key's TTL. This PR does not use TTL at all.

Change

  • ban! writes the ban expiration time (Time.now.to_f + bantime.to_f) as the value of the ban key instead of 1. The key name, the TTL (bantime), the counters and the filter/banned?/reset behavior are unchanged.
  • New Fail2Ban.retry_after(discriminator), inherited by Allow2Ban. It reads the key with the regular cache.read and returns:
    • the remaining seconds rounded up to an Integer, so a client never retries before the ban is lifted (minimum 1 while the key still exists);
    • nil if the discriminator is not banned (no key, expired, or reset);
    • nil for bans written by previous versions (value 1), whose expiration time is unknown. banned? is still true for them.
  • Repeated requests during a ban do not change the stored expiration time, because filter returns early on banned? as before.
  • README: documents retry_after with a blocklisted_responder example that sets Retry-After.

Store portability and compatibility

  • Only cache.read/cache.write are used, so this works on every supported store: MemoryStore, Dalli::Client (plain and ConnectionPool), ActiveSupport::Cache::MemCacheStore, Redis, Redis::Store and ActiveSupport::Cache::RedisCacheStore. Raw stores return the value as a String (Float#to_s round-trips exactly), and retry_after reads both forms.
  • Rolling upgrades and rollbacks are safe. Old code checks cache.read(key) ? true : false, and the new value is truthy. New code treats an old 1/"1" as banned with an unknown expiration time.
  • The expiration time comes from the clock of the process that created the ban, so clocks that drift between servers shift retry_after by the same amount.
  • Not related to this PR: with dalli 4/5, bans are not written to a plain Dalli::Client at all (Fix Fail2Ban and Allow2Ban not banning with dalli 4 and 5 #721). That PR's value.to_s fix also covers the Float written here. Instrument fail2ban and allow2ban bans #720 (a notification when a ban is created) is separate as well.

Tests

  • New spec/fail2ban_retry_after_spec.rb, run for both Fail2Ban and Allow2Ban on MemoryStore with Timecop. It covers: the stored value, the exact remaining time as time moves, partial seconds rounded up, no extension on further requests, nil after expiry and after reset, String values from raw stores, the minimum of 1, and legacy 1/"1".
  • The shared store acceptance examples (spec/support/cache_store_helper.rb) now check retry_after after a ban for both strategies. They also run a real-clock "expires bans" case (bantime: 1, then sleep), so every real backend covers write → read → expiry.
  • Existing specs that expected the ban value 1 now expect a future timestamp.
  • Run locally with real redis 7.0.5 and memcached 1.6 (Docker): bundle exec rake test passes for every appraisal gemfile on Ruby 4.0.7 (16 gemfiles) and on Ruby 3.4.8 (the 14 gemfiles CI runs on 3.4). On dalli3 the memcached specs (Dalli::Client, ConnectionPool + Dalli, MemCacheStore) actually ran against the memcached server. rubocop is clean on lib and spec.
  • With dalli 4/5 (not in the CI matrix yet), the dalli specs fail on main too, because of Fix Fail2Ban and Allow2Ban not banning with dalli 4 and 5 #721. With Fix Fail2Ban and Allow2Ban not banning with dalli 4 and 5 #721's one-line fix applied locally, they all pass with this change.
  • Ruby < 3.4 was not run locally; that is left to CI.

🤖 Generated with Claude Code

Fail2Ban (and Allow2Ban) now store the ban expiration time as the value of
the ban key instead of 1, so the remaining ban time can be read with plain
cache reads on every supported store, including memcached, without a TTL API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant