Throttlebeam
Keyed debounce, throttle and idempotency primitives on OTP.
Every key gets its own short-lived process, started on demand under a
DynamicSupervisor and found through a Registry. A busy key never
queues behind an unrelated one, and nothing is left running once a
key's work is done.
Installation
def deps do
[
{:throttlebeam, "~> 0.2.0"}
]
end
The application starts its own supervision tree. There is nothing to add to yours.
Plug is an optional dependency. Add {:plug, "~> 1.14"} to your own
deps if you want the HTTP middleware; Phoenix apps already have it.
Debounce vs throttle
Debounce waits for a quiet period. Every call for a key resets the timer, and the function runs once calls for that key stop arriving for the full delay. Use it for autosave or search-as-you-type.
Throttle runs the first call for a key immediately and rejects further calls until the interval has passed. Use it to cap how often an expensive action can fire.
# Runs once, 300ms after the last call for "search:user_42".
:ok = Throttlebeam.Debounce.call("search:user_42", 300, fn -> Search.run(query) end)
# Runs now and returns :executed, or returns :throttled inside the window.
case Throttlebeam.Throttle.call("save_draft:doc_9", 2_000, fn -> Drafts.save(doc) end) do
:executed -> :ok
:throttled -> :skipped
end
Throttle is leading edge: calls inside the window are dropped, not deferred to the end of it.
The function runs inside the key's own process. A slow function only
delays that key, but a throttled caller waits for it to finish, so keep
the function short or hand heavy work to a Task.
Idempotency
Run an operation at most once per key. A client that retries a request sends the same key, and gets the first request's result back instead of running the operation again. This includes duplicates that arrive while the first request is still running: they wait for it and share its result.
case Throttlebeam.Idempotency.run(idempotency_key, fn -> Payments.charge(order) end,
scope: tenant_id,
fingerprint: order.id
) do
{:ok, charge} -> charge # ran in this call
{:replay, charge} -> charge # another call already ran it
{:error, :fingerprint_mismatch} -> ... # key reused for a different request
{:error, :timeout} -> ... # the first request is still running
end
| Option | Default | Meaning |
|---|---|---|
:scope |
nil |
Namespace for keys, for example a tenant id |
:fingerprint |
nil |
Describes the request; a reused key with a different fingerprint is rejected |
:ttl |
24 hours | How long a completed result is replayed |
:lease |
30 seconds | How long the first request may hold the key; set it above your slowest operation |
:wait_timeout |
30 seconds | How long a duplicate waits for the first request |
If the function raises, the key is released so a retry can run it again. If the process running it dies, a waiting duplicate takes over.
Lower-level reserve/2, complete/3 and release/2 are available when
the result is produced somewhere other than a single function call.
Plug middleware
plug Plug.Parsers, parsers: [:json], json_decoder: Jason
plug Throttlebeam.Plug.Idempotency, scope: &MyApp.Tenancy.id/1
plug :match
plug :dispatch
Requests carrying an Idempotency-Key header run once; repeats get the
stored status, body and content type back with an
idempotent-replayed: true header.
| Situation | Response |
|---|---|
| Key reused with a different method, path, query or body | 422 |
Duplicate waited past :wait_timeout, or the original outlived its :lease |
409 |
| Key over 255 bytes, or more than one key header | 400 |
Handler returned 5xx |
Not stored; the retry runs again |
Put it after Plug.Parsers so the body is part of the fingerprint, and
before :match so a replay skips routing. Only POST and PATCH are
guarded by default; pass methods: to change that.
How results are stored
Processes exist only while a request is in flight. Completed results live in an ETS table, are checked for expiry on every read, and are swept once a minute. Restarting the application clears stored results, which has the same effect as them expiring early.
Results are kept in memory on the node that served the request. Across several nodes, route a given key to the same node or use a shared store.
License
MIT, see LICENSE.