This module implements a per-key fixed window rate-limiting algorithm.
Like the standard fixed window algorithm, requests are counted within a window of
duration scale. Unlike the standard fixed window — which aligns every key to the
same wall-clock boundary (multiples of scale since the Unix epoch) — this variant
anchors each key's window to that key's first hit.
For example, with a scale of 60 seconds:
- User A's first request at
12:00:37→ A's window runs until12:01:37 - User B's first request at
12:00:51→ B's window runs until12:01:51
Once a key's window expires, the next hit for that key opens a fresh window starting at that moment.
The algorithm
- When a request comes in for a key:
- If the key has an active window (
expires_at > now), increment its counter. - Otherwise, start a new window: reset the counter to
incrementand setexpires_at = now + scale.
- If the key has an active window (
- If the counter is
<= limit→ allow. Otherwise → deny and return time until the current window expires. - Expired entries are cleaned up by the periodic cleanup task.
When to use this vs :fix_window and :sliding_window
The 2x boundary burst that affects :fix_window is still theoretically possible
here, just at a per-key boundary instead of a globally synchronized one. Example:
- 100 requests at
12:00:37(window[12:00:37, 12:01:37)) - 100 more at
12:01:38(window[12:01:38, 12:02:38)) - That's 200 requests in roughly one second.
The practical benefit is that boundaries are not globally synchronized. With
:fix_window, every key flips at the same wall-clock instant (e.g. every minute on
the minute), so an attacker who knows your scale can time bursts deterministically.
With :fix_window_per_key, each key's boundary is at a different moment, and a key
has to wait a full scale between burst opportunities.
Choose :fix_window_per_key when:
- You want
:fix_window-style simplicity and the same one-entry-per-key memory footprint, but find globally-synchronized boundaries undesirable. - You're familiar with the Redis
INCR + EXPIRE NXrate-limiting pattern — this is essentially the same algorithm.
Choose :fix_window when boundaries aligned to the wall clock are useful for your
use case (clear time-based quotas like "100 requests per minute, starting on the
minute").
Choose :sliding_window when you need precise enforcement — never more than limit
in any scale-length interval. It costs more memory (one entry per request) and
CPU per check, but eliminates the boundary burst entirely.
Options
:clean_period- How often to run the cleanup process (in milliseconds). Defaults to 1 minute. The cleanup process removes expired entries.
Example
Example configuration:
MyApp.RateLimit.start_link(
clean_period: :timer.minutes(5),
)Example usage:
defmodule MyApp.RateLimit do
use Hammer, backend: :ets, algorithm: :fix_window_per_key
end
MyApp.RateLimit.start_link(clean_period: :timer.minutes(1))
# Allow 10 requests per second
MyApp.RateLimit.hit("user_123", 1000, 10)
Summary
Functions
Cleans up all of the expired entries from the table.
Returns the expiration time (in milliseconds) of the current window for a given key.
Returns the current count for a given key.
Checks if a key is allowed to perform an action based on the per-key fixed window algorithm.
Increments the counter for a given key without performing a limit check.
Sets the counter for a given key, refreshing the window to now + scale.
Functions
@spec clean(config :: Hammer.ETS.config()) :: non_neg_integer()
Cleans up all of the expired entries from the table.
@spec expires_at(table :: atom(), key :: term(), scale :: pos_integer()) :: non_neg_integer()
Returns the expiration time (in milliseconds) of the current window for a given key.
Returns 0 if the key has no active window.
@spec get(table :: atom(), key :: term(), scale :: pos_integer()) :: non_neg_integer()
Returns the current count for a given key.
Returns 0 if the key has no active window (either never hit, or window has expired).
@spec hit( table :: atom(), key :: term(), scale :: pos_integer(), limit :: pos_integer(), increment :: pos_integer() ) :: {:allow, non_neg_integer()} | {:deny, non_neg_integer()}
Checks if a key is allowed to perform an action based on the per-key fixed window algorithm.
@spec inc( table :: atom(), key :: term(), scale :: pos_integer(), increment :: pos_integer() ) :: non_neg_integer()
Increments the counter for a given key without performing a limit check.
@spec set( table :: atom(), key :: term(), scale :: pos_integer(), count :: non_neg_integer() ) :: non_neg_integer()
Sets the counter for a given key, refreshing the window to now + scale.