Rate limit¶
Someone keeps hitting “forgot password.” Same email, fifty times in a minute. Your mailer is fine — your abuse budget is not.
Rate limiting is the turnstile at the front door: it counts how many times a task may start in a time window — per user, per IP, or for the whole app. Cheap to call, expensive to abuse? This is the knob.
rate_limit tracks those starts in Stores. It runs once per execute — retries inside retry_on do not eat extra quota. When the turnstile is full, work never runs.
Rate limits vs circuit breakers
Rate limits count invocations — “this caller has had enough tries.” Circuit breakers count failures — “this dependency is sick, stop hammering it.” Password resets and OTP sends usually want a rate limit; flaky inventory APIs usually want a breaker. Need both? Use both — see Runtime order.
Basic usage¶
class SendPasswordReset < CMDx::Task
rate_limit :email, max: 5, per: 60
required :email, coerce: :string
def work
Mailer.password_reset(email).deliver_later
end
end
5.times { SendPasswordReset.execute(email: "user@example.com") } # success
result = SendPasswordReset.execute(email: "user@example.com")
result.failed? # => true
result.metadata[:code] # => :rate_limited
result.metadata[:retry_after] # => seconds (when the backend exposes TTL)
| Option | Default | Plain-English meaning |
|---|---|---|
max: |
(required) | How many runs you allow in the window (or bucket capacity for :leaky_bucket) |
per: |
(required) | Window length in seconds (:fixed_window) or seconds to “leak” one unit (:leaky_bucket) |
limiter: |
:fixed_window |
Built-in or registered strategy — see Limiters |
cost: |
1 |
How many units one admitted run costs — only :leaky_bucket reads this; :fixed_window always counts one start per execute |
namespace: |
"cmdx:rl" |
First piece of store keys |
on_limit: |
:fail |
:fail → failed result; :skip → skipped result (metadata[:code] == :rate_limited) |
if: / unless: |
nil |
Turn rate limiting on or off for this run |
Pass rate_limit false on a subclass to turn off an inherited limit.
Want one counter for the whole task type? Use rate_limit :class, … or omit the key and pass only max: / per: — same bucket either way.
Key resolvers¶
Pick who shares one counter. Same shapes as Idempotency key resolvers and Circuit breaker scoping:
| Form | How the segment is resolved |
|---|---|
:class |
No extra segment — one bucket per task class (namespace:TaskName) |
Symbol (e.g. :email) |
context[email] after inputs run |
String |
Used literally as the segment |
Block / Proc |
instance_exec on the task — self is the task; optional \|task\| matches callbacks |
Other #call |
call(task) — e.g. a small resolver object |
rate_limit :email, max: 5, per: 60 # one bucket per email address
rate_limit :class, max: 100, per: 3600 # one bucket for the whole task class
rate_limit max: 50, per: 3600 # same as :class when no key is given
When the resolver is not :class and the segment is blank (nil or empty string), rate limiting steps aside for that run — same idea as Idempotency with a blank key. Keys are built with CMDx::StoreKey.task_key(..., class_scoped: true) so :class does not read context.
Quota is charged at the door¶
Think of each allowed execute as swiping in once. CMDx charges that swipe before the circuit breaker, locks, idempotency, or work.
If work raises, calls fail! / skip!, or the run retries inside retry_on, CMDx does not refund the swipe. One outer execute still used at most one admission — retries do not visit the turnstile again.
Size your limits for starts, not “only successful finishes.”
Store keys¶
Base keys use CMDx::StoreKey.task_key(task, namespace:, resolver:, class_scoped: true) — colon-separated segments, each coerced with CMDx::Stores.key! (empty segments raise ArgumentError).
Examples:
cmdx:rl:SendPasswordReset:user@example.comcmdx:rl:SendPasswordReset(withrate_limit :classor options-onlymax:/per:)
:fixed_window increments the base key. :leaky_bucket stores "level:last_at" at #{store_key}:lb with a TTL derived from capacity and per.
Use a shared store in production (Stores::Redis on CMDx.configuration.store). The in-memory default only counts inside one process. See Stores.
Result metadata¶
When the turnstile says no, skip! / fail! merge the same payload as :rate_limit_exceeded telemetry into result.metadata:
| Key | Meaning |
|---|---|
:code |
:rate_limited |
:rate_limit_key |
Full base store key |
:limiter |
Strategy name (:fixed_window, :leaky_bucket, or your registered symbol) |
:max, :per |
Declared limits |
:count |
Present for :fixed_window (counter after incr, including the denied attempt) |
:level |
Present for :leaky_bucket (projected fill when denied) |
:retry_after |
Seconds until retry might succeed when the backend or limiter computes it |
Admitted runs do not add rate-limit fields to the result — subscribe to :rate_limit_admitted if you need per-run admission metrics.
Runtime order¶
After inputs resolve, CMDx checks the turnstile before breakers, locks, and idempotency:
resolve_inputs! → rate_limit → circuit_breaker → lock_with → idempotent_by → retry loop { work } → verify_outputs!
Retries do not re-run the limiter. One outer execution costs at most one admission (for the configured cost).
Limiters¶
A limiter is the little algorithm that answers: “Is there room for one more start?” Built-ins live in CMDx::Limiters — same registry idea as CMDx::Retriers for retry jitter.
Resolution order:
limiter:as aProc→instance_execon the task- Symbol →
task.class.limiterswhen the task registeredregister :limiter, otherwiseCMDx.configuration.limiters
Each callable gets a CMDx::RateLimit::Quota (the question) and must return a CMDx::RateLimit::Admission (yes or no, plus optional hints for messages and metadata).
Quota field |
Role |
|---|---|
task |
Task being admitted |
store_key |
Base key (append suffixes if your strategy needs more state) |
max, per, cost |
Declared limits |
limiter |
Resolved strategy spec |
options |
Frozen declaration hash (namespace, gates, etc.) |
Admission field |
Role |
|---|---|
admitted |
true to run work, false to signal over limit |
count |
Optional counter for messages / metadata (:fixed_window) |
level |
Optional fill for messages / metadata (:leaky_bucket) |
retry_after |
Optional hint in seconds |
limiter |
Name recorded on telemetry and metadata |
Built-in limiters¶
Fixed window — a simple counter with a TTL. Easy to reason about; you can get a small burst right when a window rolls over.
Leaky bucket — capacity refills slowly (per seconds per unit). Smoother traffic shape; state lives at store_key:lb.
Custom limiters¶
Register globally or per task with register :limiter:
CMDx.configure do |config|
config.limiters.register(:per_tenant_strict) do |quota|
count = CMDx::Stores.incr(quota.store_key, ttl: quota.per)
admitted = count <= quota.max
retry_after = admitted ? nil : CMDx::Stores.remaining_ttl(quota.store_key)
CMDx::RateLimit::Admission.new(
admitted:,
count:,
level: nil,
retry_after:,
limiter: :per_tenant_strict
)
end
end
class SendInvoice < CMDx::Task
register :limiter, :app_only, MyApp::InvoiceLimiter
rate_limit :account_id, max: 20, per: 3600, limiter: :app_only
end
Pass a Proc as limiter: and CMDx runs it with instance_exec on the task, passing the Quota as the block argument. It must return an Admission. Unknown limiter symbols raise CMDx::UnknownEntryError with registered names in the message.
Telemetry¶
When rate limiting is active for a run (enabled, gates pass, key resolves), CMDx emits telemetry only if someone subscribed — unused events cost nothing.
| Event | When | Extra event.payload keys |
|---|---|---|
:rate_limit_admitted |
Admission succeeded, before circuit breaker / lock / work | rate_limit_key, limiter, max, per; optional count, level, retry_after |
:rate_limit_exceeded |
Admission denied | code: :rate_limited, rate_limit_key, limiter, on_limit:, max, per; optional count, level, retry_after |
:rate_limit_exceeded fires before skip! / fail!, matching metadata[:code] == :rate_limited on the result.
CMDx.configure do |config|
config.telemetry.subscribe(:rate_limit_exceeded, ->(event) {
StatsD.increment("cmdx.rate_limit.denied", tags: [
"task:#{event.task}",
"limiter:#{event.payload[:limiter]}"
])
})
end
Inheritance¶
Subclasses inherit the parent’s rate limit. rate_limit with no arguments returns the current config (including inherited). Pass a new key to replace it; pass only options to merge (e.g. rate_limit per: 120 on the subclass). rate_limit false turns it off.