KemalIdentity::Postgres::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. PostgreSQL takes the row lock for the
update, so concurrent attempts from any number of processes 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. Processes on different hosts therefore need synchronised clocks; skew
moves where a window starts and ends by the size of the skew and does not change how many
attempts it admits.
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/postgres/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
Instance methods
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.
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.
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.