An idempotency-key store and Plug for safe retries of financial mutations.
Networks drop connections and clients retry. A charge, a transfer, or a ledger posting that is submitted twice because of a timeout must apply once and only once. This library gives you a small, dependency-light way to guarantee that.
It provides two pieces:
Idempotency.Storeis an ETS-backed key store that records the lifecycle of an idempotent operation. It supports at-most-once apply, cached results, request fingerprinting, and per-entry TTL.Idempotency.Plugreads an idempotency key from a request header, replays the cached response for a repeated key, and rejects a reused key whose request body differs.
The contract follows the widely used idempotency-key pattern adopted by payment and financial APIs: the client generates a unique key per logical operation and sends it on every retry. The server applies the operation the first time, caches the outcome, and returns that same outcome for every subsequent request carrying the same key.
Add idempotency to your dependencies in mix.exs:
def deps do
[
{:idempotency, "~> 0.1"}
]
endThen fetch dependencies:
mix deps.getThe default store starts automatically with the application under the name
Idempotency.Store. To run a separately named store, add it to your own
supervision tree:
children = [
{Idempotency.Store, name: MyApp.IdempotencyStore, ttl_ms: :timer.hours(24)}
]Wrap any side-effecting function so it runs at most once per key. Repeated calls with the same key replay the cached result without running the function again.
fingerprint = :erlang.phash2({:transfer, account_id, amount})
case Idempotency.run("transfer-42", fingerprint, fn ->
Ledger.post_transfer(account_id, amount)
end) do
{:ok, result} ->
# Applied on the first call, replayed on every retry.
result
{:error, :in_progress} ->
# A concurrent call with the same key is still running.
:retry_later
{:error, {:conflict, _stored}} ->
# The key was reused with a different fingerprint.
:reject
endThe fingerprint is any term that uniquely describes the request payload. If the
same key arrives with a different fingerprint, the call is reported as a conflict
instead of silently replaying the old result. Pass :any to skip fingerprint
matching.
Add the Plug to a pipeline that handles mutating requests. Place it after a body parser so the request payload is available for fingerprinting.
plug Plug.Parsers,
parsers: [:json],
json_decoder: Jason
plug Idempotency.Plug,
store: Idempotency.Store,
header: "idempotency-key",
methods: ~w(POST PUT PATCH),
ttl_ms: :timer.hours(24)Behavior:
- A request without the key header passes through untouched, so clients opt in.
- The first request with a key runs normally; its response status and body are cached when sent.
- A repeated request with the same key and body replays the cached response and
sets the
idempotency-replayed: trueresponse header. - A repeated request with the same key but a different body is rejected with
422 Unprocessable Entity. - A request that arrives while the first is still in flight receives
409 Conflict.
The library implements the idempotency-key convention common to financial and payment APIs:
- The client supplies a unique key per logical operation in a request header
(default
Idempotency-Key). - The server stores the key together with a fingerprint of the request and the outcome of the operation.
- Retries carrying the same key receive the original outcome; the operation is never applied more than once.
- A key reused with a different request payload is treated as a client error.
- Stored entries expire after a configurable TTL so keys can be safely reused once the retry window has passed.
Idempotency.Store options:
| Option | Default | Description |
|---|---|---|
:name |
Idempotency.Store |
Registered process name. |
:ttl_ms |
24 hours | Default entry lifetime in milliseconds. |
:sweep_ms |
60 seconds | Interval between background expiry sweeps. |
Idempotency.Plug options:
| Option | Default | Description |
|---|---|---|
:store |
Idempotency.Store |
Store server to use. |
:header |
"idempotency-key" |
Request header carrying the key. |
:methods |
["POST","PUT","PATCH"] |
HTTP methods to guard. |
:ttl_ms |
store default | Lifetime of a cached response. |
mix deps.get
mix testReleased under the MIT License. See LICENSE.