Skip to content

Maxed-OSS/idempotency

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Idempotency

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.Store is 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.Plug reads 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.

Installation

Add idempotency to your dependencies in mix.exs:

def deps do
  [
    {:idempotency, "~> 0.1"}
  ]
end

Then fetch dependencies:

mix deps.get

The 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)}
]

Usage

Protect a function

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
end

The 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.

Guard HTTP endpoints

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: true response 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 standard it speaks

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.

Configuration

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.

Development

mix deps.get
mix test

License

Released under the MIT License. See LICENSE.

About

An idempotency-key store and Plug for safe retries of financial mutations: a charge, transfer, or ledger posting submitted twice applies once and only once.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages