Sovite.Abuse.RateLimit (sovite v0.6.0)

Copy Markdown View Source

Counts events per key over sliding windows, for limits such as "100 messages per hour per client address".

children = [
  {Sovite.Abuse.RateLimit, name: MyApp.RateLimit}
]

case RateLimit.hit(MyApp.RateLimit, ip, 100, 3_600_000) do
  :ok -> accept()
  :limited -> defer()
end

Each window is split into fixed buckets of its own length, and the count is estimated from the current bucket plus the share of the previous one that still falls inside the window. This needs two counters per key and window, and is accurate enough for abuse limits. One table can hold limits with different windows for the same key.

Callers update an ETS table directly and never wait on the process, which only owns the table and purges old buckets. Concurrent hits can refuse a little more than strictly needed, never less. State is in memory: a restart forgets all counts.

Options

  • :name - an atom, also used as the ETS table name. Required.
  • :cleanup_interval - milliseconds between purges of old buckets. Defaults to one minute.
  • :clock - a zero-arity function returning the current time in milliseconds. Defaults to the time since the VM started.
  • :replicate - when true, counted events are also kept as deltas, for take_deltas/1. Defaults to false.

Sharing counts

Several nodes can enforce one limit together: each takes the deltas of its table (take_deltas/1) now and then, and sends them to the others, which add them to theirs (merge/2). The nodes need the same :clock, such as &System.os_time(:millisecond)/0 on hosts with synchronized clocks, so that their buckets line up. Events counted elsewhere and not yet merged are missing from the counts, so limits are a little looser than on one node.

Telemetry

  • [:sovite, :abuse, :rate_limit, :exceeded] - %{limit, window}, %{rate_limit, key}, on the first refused hit of a key and window in each bucket.

Summary

Functions

Counts count events for key without a limit. Returns the number of events in the last window milliseconds, rounded down.

Returns the number of events for key in the last window milliseconds, rounded down.

Counts count events for key unless that would take it over limit events in the last window milliseconds. A refused hit is not counted.

Adds events counted on other nodes (see take_deltas/1).

Forgets every window of key.

Starts the rate limiter.

Returns the events counted since the last call, as {key, window, bucket, count} tuples, for merge/2 on other nodes. Empty unless started with replicate: true.

Functions

add(name, key, window, count \\ 1)

@spec add(atom(), term(), pos_integer(), pos_integer()) :: non_neg_integer()

Counts count events for key without a limit. Returns the number of events in the last window milliseconds, rounded down.

count(name, key, window)

@spec count(atom(), term(), pos_integer()) :: non_neg_integer()

Returns the number of events for key in the last window milliseconds, rounded down.

hit(name, key, limit, window, count \\ 1)

@spec hit(atom(), term(), non_neg_integer(), pos_integer(), pos_integer()) ::
  :ok | :limited

Counts count events for key unless that would take it over limit events in the last window milliseconds. A refused hit is not counted.

merge(name, deltas)

@spec merge(atom(), [{term(), pos_integer(), integer(), pos_integer()}]) :: :ok

Adds events counted on other nodes (see take_deltas/1).

reset(name, key)

@spec reset(atom(), term()) :: :ok

Forgets every window of key.

start_link(opts)

@spec start_link(keyword()) :: GenServer.on_start()

Starts the rate limiter.

take_deltas(name)

@spec take_deltas(atom()) :: [{term(), pos_integer(), integer(), pos_integer()}]

Returns the events counted since the last call, as {key, window, bucket, count} tuples, for merge/2 on other nodes. Empty unless started with replicate: true.