class

KemalIdentity::SQLite::FixedWindowRateLimiter

Inherits KemalIdentity::SweepableRateLimiter < KemalIdentity::RateLimiter < Reference < Object

A fixed-window limiter whose counters every process sees.

The same strategy, the same contract and the same documented burst as the in-memory KemalIdentity::FixedWindowRateLimiter — the difference is only where the count lives. In memory, six workers behind a load balancer each allow the full limit, which blueprints/0025 (OPS-01) measured at 2.2× the intended total. Here they share one row.

One statement, so no process can read a stale count

Counting, deciding and reopening an elapsed window are a single INSERT ... ON CONFLICT DO UPDATE ... RETURNING. SQLite holds the database write lock for the statement, so concurrent attempts from every process are serialised and each sees the count the previous one left. A read followed by a write would let two processes both see "nine" and both allow the tenth.

Time comes from the clock, not the database

So that the shared contract can drive it with a TestClock, exactly as it drives the in-memory limiters. Timestamps are stored as UTC text in one fixed format, so they compare correctly as strings.

Shared between processes on one host, not between hosts

Every process opening the same database file shares the counters — several workers behind a local reverse proxy, say. Open it with journal_mode=wal&busy_timeout=5000: without a busy timeout a contended write fails at once with database is locked, which this reports as an unavailable store. Across hosts, use KemalIdentity::Postgres::FixedWindowRateLimiter.

When the database does not answer

Verdict.unavailable, never an exception and never a guess — the contract on KemalIdentity::RateLimiter#consume, and blueprints/0023 for why. Login stays fail-closed unless the application wraps this in FailOpenRateLimiter.

Needs migrations/sqlite/20261008090000_create_auth_rate_limits.sql. Include it in the sweep (KemalIdentity::Sweeper does so when this is rate_limiter:), or elapsed rows accumulate for as long as somebody keeps inventing keys.

Constructors

new(db : DB::Database, limit : Int32, window : Time::Span, clock : Clock = SystemClock.new)
Source

Instance methods

consume(key : String) : Verdict

Counts one attempt against key and says whether it may proceed.

Called before any I/O and before any hashing. A denial must be cheap, or the limiter becomes the very lever it exists to remove.

Must not raise for a storage failure. A limiter whose Redis is unreachable returns Verdict.unavailable and lets the application's configured policy decide, because the answer differs per endpoint: a login should refuse rather than run unmetered, while a less sensitive action may prefer to stay up. An exception here would make that choice for everybody, and would surface as a 500 rather than as either policy.

Source
delete_expired(now : Time) : Int32

Every counter whose window has ended — each by its own deadline, whichever limiter wrote it. <=, the boundary consume reopens a window on, so nothing consume would still honour is deleted.

Source
limit
Source
reset(key : String) : Nil

Clears the count for key, after a successful authentication.

Idempotent, and safe for a key that was never consumed. Must not raise, including when the store is unavailable: a reset that does not happen leaves somebody throttled slightly longer than they earned, which is not worth failing a successful login over.

Source
window
Source