Throttler is a lightweight Elixir DSL for rate-limiting events across arbitrary scope and key combinations — perfect for throttling notification delivery, message sends, job dispatches, and more.
Backed by Postgres and Ecto, it guarantees race-safety using SELECT FOR UPDATE, making it ideal for distributed or concurrent systems.
- ✅ Declarative throttling with a clean DSL
- ✅ Race-safe via Postgres locking
- ✅ Time-window enforcement (e.g., once per hour, max 3 per day)
- ✅ General-purpose: use it for email, SMS, alerts, tasks, etc.
- ✅ Built with plain Ecto — no special dependencies
Add to your mix.exs:
def deps do
[
{:throttler, "~> 0.1.0"}
]
endThrottler requires two tables to store throttle state and events. You can find a migration template at priv/repo/migrations/create_throttler_tables.exs.template.
Create a new migration in your application:
mix ecto.gen.migration create_throttler_tablesThen add the following to your migration:
create table(:throttler_throttles) do
add :scope, :string, null: false
add :key, :string, null: false
add :last_occurred_at, :utc_datetime_usec
timestamps()
end
create unique_index(:throttler_throttles, [:scope, :key])
create table(:throttler_events) do
add :scope, :string, null: false
add :key, :string, null: false
add :occurred_at, :utc_datetime_usec, null: false
end
create index(:throttler_events, [:scope, :key])defmodule MyApp.Notifications do
use Throttler, repo: MyApp.Repo
def maybe_send(scope) do
throttle scope, "weekly_digest", max_per: [hour: 1, day: 3] do
MyMailer.send_digest(scope)
end
end
endcase MyApp.Notifications.maybe_send("user:123") do
{:ok, :sent} -> :ok
{:error, :throttled} -> :skip
{:error, {:exception, e}} -> report_exception(e)
endYou can configure the repo globally in your application config instead of specifying it in each module:
# config/config.exs
config :throttler, repo: MyApp.RepoThen use Throttler without specifying the repo:
defmodule MyApp.Notifications do
use Throttler # No repo: option needed!
def maybe_send(scope) do
throttle scope, "weekly_digest", max_per: [hour: 1, day: 3] do
MyMailer.send_digest(scope)
end
end
endThe module-level configuration takes precedence over the global configuration if both are provided:
# This will use MySpecialRepo, not the globally configured one
defmodule MyApp.SpecialNotifications do
use Throttler, repo: MySpecialRepo
endFor testing purposes, you can configure a custom DateTime module to mock time-related functions:
# config/test.exs
config :throttler, date_time_module: MyApp.MockDateTimeYour mock module should implement utc_now/0, add/3, and compare/2 functions compatible with Elixir's DateTime module:
defmodule MyApp.MockDateTime do
def utc_now, do: ~U[2024-01-01 12:00:00.000000Z]
def add(datetime, amount, unit), do: DateTime.add(datetime, amount, unit)
def compare(dt1, dt2), do: DateTime.compare(dt1, dt2)
endThis is particularly useful for:
- Testing time-sensitive throttling behavior
- Ensuring deterministic test results
- Simulating specific time scenarios
All logic is wrapped in a Postgres transaction and uses SELECT FOR UPDATE to prevent race conditions across parallel processes or nodes.
The throttle block is already wrapped in a database transaction. Do not use Repo.transaction inside the throttle callback, as nested transactions can produce unexpected results:
# ❌ AVOID THIS
throttle "user:123", "notification", max_per: [hour: 1] do
Repo.transaction(fn ->
# This creates a nested transaction - don't do this!
send_notification()
end)
end
# ✅ DO THIS INSTEAD
throttle "user:123", "notification", max_per: [hour: 1] do
# Your code runs inside a transaction already
send_notification()
endIf you need to perform additional database operations, they will automatically be part of the same transaction and will be rolled back if an exception occurs.
Throttler exports formatter rules for the throttle macro. If you're using Throttler in your project and want parentheses-free formatting, add this to your .formatter.exs:
[
import_deps: [:throttler, ...],
# ... rest of your formatter config
]This allows you to write:
throttle "user:123", "daily_report", max_per: [day: 1] do
send_report()
endThe max_per option accepts a keyword list where keys are time units and values are the maximum number of events allowed in that time period:
max_per: [
minute: 5, # Max 5 per minute
hour: 20, # Max 20 per hour
day: 100 # Max 100 per day
]Supported time units: :minute, :hour, :day
The most restrictive limit will be enforced. For example, if you have [hour: 10, day: 20] and 10 events have already been sent in the last hour, further attempts will be throttled even if the daily limit hasn't been reached.
You can bypass throttling limits by passing force: true. This is useful for critical operations that must execute regardless of throttle limits:
# Normal throttling - respects limits
throttle "user:123", "newsletter", max_per: [day: 1] do
send_newsletter()
end
# Force execution - always runs
throttle "user:123", "newsletter", max_per: [day: 1], force: true do
send_urgent_security_alert() # This will always execute
endWhen force: true is set:
- The block will always execute regardless of throttle limits
- The event is still recorded in the database for tracking
- The
last_occurred_attimestamp is updated - Useful for admin overrides, critical alerts, or testing
case MyApp.maybe_notify(user_id, force: admin_override?) do
{:ok, :sent} -> Logger.info("Notification sent")
{:error, :throttled} -> Logger.info("Throttled (won't happen with force: true)")
endYou can use any string for scope and key. Examples:
| Use Case | Scope | Key |
|---|---|---|
| Email throttling | "user_123" |
"appointment_reminder" |
| Push notification | "device:abc" |
"low_battery" |
| Job dispatch | "customer:42" |
"export:csv" |
Important: The throttler_events table will grow over time as events are recorded. You should periodically clean up old events to prevent unbounded growth.
Add a background job to clean up old events periodically:
# In a Phoenix app with Oban
defmodule MyApp.ThrottlerCleanupJob do
use Oban.Worker, queue: :maintenance
@impl Oban.Worker
def perform(_job) do
# Clean up events older than 30 days (uses global repo)
deleted_count = Throttler.cleanup(days: 30)
{:ok, %{deleted_events: deleted_count}}
end
endThe cleanup function accepts several options:
# Use the globally configured repo
Throttler.cleanup(days: 7)
Throttler.cleanup(hours: 48)
# Or specify a repo explicitly
Throttler.cleanup(repo: MyApp.Repo, days: 7)
# Clean up with a specific DateTime cutoff
cutoff = DateTime.add(DateTime.utc_now(), -7, :day)
Throttler.cleanup(cutoff)- Events are only needed within the longest configured time window
- Cleanup functions return the number of deleted records
- Consider running cleanup daily or weekly depending on your event volume
PRs welcome! This project is small, fast, and designed to be easy to understand.