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"}
]
end
Throttler 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_tables
Then 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
end
case MyApp.Notifications.maybe_send("user:123") do
{:ok, :sent} -> :ok
{:error, :throttled} -> :skip
{:error, {:exception, e}} -> report_exception(e)
end
You can configure the repo globally in your application config instead of specifying it in each module:
# config/config.exs
config :throttler, repo: MyApp.Repo
Then 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
end
The 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
end
For testing purposes, you can configure a custom DateTime module to mock time-related functions:
# config/test.exs
config :throttler, date_time_module: MyApp.MockDateTime
Your 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)
end
This 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()
end
If 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()
end
The 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
end
When 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_at
timestamp 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)")
end
You 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
end
The 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.