Skip to content

Repository files navigation

rack-idempotency-key

Rack middleware implementing the Idempotency-Key pattern, so that retried API requests are applied exactly once.

The problem

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.

Usage

# config.ru, or Rails: config/application.rb
use Rack::IdempotencyKey, store: Rack::IdempotencyKey::MemoryStore.new

Multi-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}

Semantics

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.

Options

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 24h

Any object implementing claim / complete / release (protocol documented in MemoryStore) can act as the store — Memcached, Postgres, your pick.

Design notes

  • The claim is atomic. The race between two concurrent retries is settled in the store (a mutex in-process, SET NX EX in 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; redis is only needed if you use RedisStore.

Development

rake test                                        # unit tests (no services needed)
REDIS_URL=redis://localhost:6379/0 rake test     # + RedisStore integration tests

License

MIT

About

Rack middleware for the Idempotency-Key pattern — retried API requests applied exactly once. Memory + Redis stores.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages