Malachi.RateLimiter (malachi v0.22.7)

View Source

Token bucket rate limiter using ETS for high-performance limits. Tracks requests per IP/user across different actions.

Schema

The ETS table stores three types of entries:

  • {{identifier, action}, {count, last_refill_ms, window_start_ms, window_ms}} - Token buckets (check_limit/3); as with the sharded counters, the entry carries the window it was written under so that ageing it never depends on configuration read somewhere else
  • {{identifier, action, window_start_ms, shard}, used, window_ms} - Sharded window counters (check_limit_in_caller/3), the window start in Erlang monotonic ms; the entry carries the window it was counted under so the cleanup can age it without having to re-read config that may since have changed
  • {{:blocked, identifier, action}, count} - Blocked request counters

Actions

  • :auth - TCP authentication attempts (tracked by IP)
  • :dashboard_auth - Dashboard logins, and dashboard tokens that do not validate (tracked by IP)
  • :dashboard_api - Authenticated dashboard requests (tracked by session, under a digest of its token)
  • :publish - Produce requests (tracked by authenticated username)
  • :subscribe - Subscribe requests (tracked by authenticated username)

All five are enforced, per node. :publish and :subscribe are off by default (limit 0); an operator opts in by configuring a limit. :dashboard_api is on by default (300 per minute per session) and a limit of 0 turns it off. Enforcement is per node, not a cluster-wide quota, and a produce costs one token per request, not per record.

:dashboard_api is keyed by session rather than by user. The NorthGuard material this project follows says nothing about operator consoles or quotas; a per principal key is what the Kafka lineage would suggest, but that governs produce and consume clients. This limit protects an operator surface, and keying it by user would put two operators sharing one account back into the bucket this action exists to split them out of. Its blocked counters are reaped with their bucket by the periodic cleanup, because a session identifier outlives its session only as garbage.

Two doors, on purpose

check_limit/3 is the token bucket above, read and written through the GenServer, so the read-modify-write is serialized and the limit is exact. The auth paths use it: they are cold (one check per connection or per login) and they are security controls, so exactness is worth a round-trip.

check_limit_in_caller/3 is a different algorithm for a different problem. Produce is the hottest path in the system, and the publish quota is keyed by user, so every connection belonging to one client contends for one quota. The obvious implementation (this module's own bucket body, just run in the caller instead of the GenServer) is the wrong answer, because write_concurrency buys nothing when every caller writes the SAME key. Measured on an 8-core machine against one hot key, at 64 concurrent processes:

bucket lookup + insert, in the caller     120k checks/s   <- seven times WORSE than the GenServer
bucket through the GenServer              868k checks/s
one atomic update_counter, one key        229k checks/s
update_counter, sharded per scheduler    23.8M checks/s

So this door counts a fixed window sharded per scheduler: the key carries :erlang.system_info(:scheduler_id), so racing callers land on different ETS keys and the check scales with cores instead of against them. Each shard holds its slice of the quota; a caller whose own shard is exhausted sweeps the others before rejecting, so an unevenly spread load does not reject early.

End to end (benchmark/rate_limit_bench.exs, the whole public function rather than the bare ETS op) this door measures 4.2M checks/s at 64 concurrent processes against the serialized door's 868k, and an unconfigured action, the shipped default, costs 51ns because it never reaches the table at all.

Every token is still claimed by one atomic update_counter, and the shard caps sum to exactly limit (the remainder is spread across the low shards, not dropped), so the count itself is exact: measured at 200 concurrent callers, a limit of n admits exactly n.

What this door gives up is the shape of the limit, not its arithmetic. A fixed window does not refill gradually, so a client can spend the tail of one window and the head of the next back to back and burst to 2x the limit across a boundary; the token bucket smooths that. The windows are named by the monotonic clock, so that burst is the only way to reach a new window: a step of the system clock cannot hand out a fresh quota mid-window. Bursting is acceptable for a throughput quota and not for an auth control, which is why the two doors exist rather than one.

Configuration

Set via environment variables or runtime config:

  • rate_limit_enabled - Enable/disable rate limiting (default: true)
  • auth_rate_limit - Max auth attempts per window (default: 10)
  • auth_rate_window_ms - Auth window duration (default: 60000)
  • publish_rate_limit - Max produce requests per window (default: 0, meaning no limit)
  • publish_rate_window_ms - Publish window duration (default: 1000)
  • subscribe_rate_limit - Max subscribe requests per window (default: 0, meaning no limit)
  • subscribe_rate_window_ms - Subscribe window duration (default: 60000)
  • dashboard_api_rate_limit - Authenticated dashboard requests per window, per session (default: 300)
  • dashboard_api_rate_window_ms - Dashboard API window duration (default: 60000)
  • rate_limit_cleanup_interval_ms - Cleanup interval (default: 300000)

Summary

Functions

The configured limit for an action that can be switched off, or nil when that action is not limited.

Check if request is within rate limits.

Like check_limit/3 in contract, but built for a hot path: a fixed window counted in per-scheduler shards, read and written in the calling process so concurrent callers do not serialize on one ETS key or on the limiter process.

Returns a specification to start this module under a supervisor.

Get statistics about rate limiting.

Get top N blocked identifiers for an action.

Gives identifier its whole allowance for action back and forgets its blocked count.

Starts the rate limiter GenServer.

Functions

action_config(atom)

@spec action_config(:publish | :subscribe | :dashboard_api) ::
  %{limit: pos_integer(), window_ms: pos_integer()} | nil

The configured limit for an action that can be switched off, or nil when that action is not limited.

:publish and :subscribe are off unless an operator configures a positive limit and window, so a limit of 0 (the default) means "no limit" and reads back as nil. :dashboard_api follows the same rule but ships on, at 300 requests per 60 seconds. This is the single reader of those config keys: the enforcement path and the dashboard both go through it so they cannot diverge.

Examples

action_config(:publish)
#=> nil                              # unconfigured, the default
#=> %{limit: 1000, window_ms: 1000}  # MALACHI_PUBLISH_RATE_LIMIT=1000

action_config(:dashboard_api)
#=> %{limit: 300, window_ms: 60000}  # the default

check_limit(identifier, action, config)

Check if request is within rate limits.

Returns :ok if allowed, or {:error, :rate_limit_exceeded, retry_after_ms} if blocked.

Parameters

  • identifier - IP address (string) for auth, username (atom/string) for publish/subscribe
  • action - Action atom (:auth, :publish, :subscribe)
  • config - Map with :limit (max requests) and :window_ms (time window)

Examples

check_limit("192.168.1.1", :auth, %{limit: 10, window_ms: 60_000})
#=> :ok

check_limit("user1", :publish, %{limit: 1000, window_ms: 1000})
#=> {:error, :rate_limit_exceeded, 850}

check_limit_in_caller(identifier, action, map)

@spec check_limit_in_caller(term(), atom(), %{
  limit: pos_integer(),
  window_ms: pos_integer()
}) ::
  :ok | {:error, :rate_limit_exceeded, non_neg_integer()}

Like check_limit/3 in contract, but built for a hot path: a fixed window counted in per-scheduler shards, read and written in the calling process so concurrent callers do not serialize on one ETS key or on the limiter process.

Use this for the publish/subscribe quotas and check_limit/3 everywhere else. Inside one window it admits exactly limit, however many callers race for it: the shard caps sum to limit and each token is claimed by one atomic update. Across a window boundary it can admit up to twice the limit (the tail of one window and the head of the next), and it never admits under the limit. See "Two doors, on purpose" above.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

get_stats()

Get statistics about rate limiting.

Returns map with total buckets and blocked request counts.

get_top_blocked(action, limit \\ 20)

Get top N blocked identifiers for an action.

Returns list of {identifier, blocked_count} tuples sorted by count descending.

Examples

get_top_blocked(:auth, 10)
#=> [{"192.168.1.100", 523}, {"10.0.0.50", 312}, ...]

reset_bucket(identifier, action)

Gives identifier its whole allowance for action back and forgets its blocked count.

Clears both doors: the token bucket of check_limit/3 and every window counter of check_limit_in_caller/3, whatever window and shard it was counted under.

Used for testing or manual intervention.

start_link(opts)

Starts the rate limiter GenServer.