Rack middleware implementing the Idempotency-Key pattern, so that retried API requests are applied exactly once.
A client POSTs a payment. The network drops after your server charged the card but before the response arrived. The client — doing the right thing — retries. Without protection, you just charged the customer twice.
The fix is the pattern used by Stripe, Adyen and most payment APIs: the client
sends a unique Idempotency-Key header, and the server guarantees that two
requests with the same key are executed once, with the recorded response
replayed to the retry.
This gem gives any Rack application (Rails, Sinatra, plain Rack) that guarantee
in one use line.
# config.ru, or Rails: config/application.rb
use Rack::IdempotencyKey, store: Rack::IdempotencyKey::MemoryStore.newMulti-process deployments (Puma workers, several hosts) need a shared store:
require "rack/idempotency_key/redis_store"
use Rack::IdempotencyKey,
store: Rack::IdempotencyKey::RedisStore.new(redis: Redis.new(url: ENV["REDIS_URL"]))Client side:
POST /orders
Idempotency-Key: 4b227777-d4dd-4fc9-8ab8-3e2f6b3f4c33
Content-Type: application/json
{"amount": 100}
| Situation | Result |
|---|---|
| No header, or GET/HEAD/OPTIONS | Passed through untouched |
| First request with a key | Executed; response recorded |
| Retry — same key, same payload | Recorded response replayed, idempotency-replayed: true header added |
| Same key while the original is still running | 409 Conflict with retry-after: 1 |
| Same key, different payload | 422 Unprocessable Content — the client is misusing the key |
| Application responds 5xx | Not recorded — the retry reaches the application again |
| Application raises | Claim released — the retry reaches the application again |
Requests are fingerprinted (SHA-256 of method, path, query and body) so an accidental key reuse with a different payload is detected instead of silently answered with an unrelated recorded response.
use Rack::IdempotencyKey,
store: Rack::IdempotencyKey::MemoryStore.new, # or RedisStore, or your own
methods: %w[POST PATCH], # default: POST PUT PATCH DELETE
header: "Idempotency-Key", # default
ttl: 86_400 # seconds a key is remembered; default 24hAny object implementing claim / complete / release (protocol documented
in MemoryStore) can act as the store — Memcached, Postgres, your pick.
- The claim is atomic. The race between two concurrent retries is settled
in the store (a mutex in-process,
SET NX EXin Redis) — not in middleware code, where it can't be settled reliably. - 5xx responses and exceptions release the key rather than recording the failure. Recording them would turn one transient failure into a permanent one for that key.
- No runtime dependencies. The middleware is pure Ruby against the Rack
spec;
redisis only needed if you useRedisStore.
rake test # unit tests (no services needed)
REDIS_URL=redis://localhost:6379/0 rake test # + RedisStore integration tests