Rack middleware ensuring at most once requests for mutating endpoints.
Add this line to your application's Gemfile:
gem 'idempotent-request'
And then execute:
$ bundle
Or install it yourself as:
$ gem install idempotent-request
- Front-end generates a unique
key
then a user goes to a specific route (for example, transfer page). - When user clicks "Submit" button, the
key
is sent in the headeridempotency-key
and back-end stores server response into redis. - All the consecutive requests with the
key
won't be executer by the server and the result of previous response (2) will be fetched from redis. - Once the user leaves or refreshes the page, front-end should re-generate the key.
# application.rb
config.middleware.use IdempotentRequest::Middleware,
storage: IdempotentRequest::RedisStorage.new(::Redis.current, expire_time: 1.day),
policy: YOUR_CLASS
To define a policy, whether a request should be idempotent, you have to provider a class with the following interface:
class Policy
attr_reader :request
def initialize(request)
@request = request
end
def should?
# request is Rack::Request class
end
end
# application.rb
config.middleware.use IdempotentRequest::Middleware,
storage: IdempotentRequest::RedisStorage.new(::Redis.current, expire_time: 1.day),
policy: IdempotentRequest::Policy
config.idempotent_routes = [
{ controller: :'v1/transfers', action: :create },
]
# lib/idempotent-request/policy.rb
module IdempotentRequest
class Policy
attr_reader :request
def initialize(request)
@request = request
end
def should?
route = Rails.application.routes.recognize_path(request.path, method: request.request_method)
Rails.application.config.idempotent_routes.any? do |idempotent_route|
idempotent_route[:controller] == route[:controller].to_sym &&
idempotent_route[:action] == route[:action].to_sym
end
end
end
end
# config/initializers/idempotent_request.rb
ActiveSupport::Notifications.subscribe('idempotent.request') do |name, start, finish, request_id, payload|
notification = payload[:request].env['idempotent.request']
if notification['read']
Rails.logger.info "IdempotentRequest: Hit cached response from key #{notification['key']}, response: #{notification['read']}"
elsif notification['write']
Rails.logger.info "IdempotentRequest: Write: key #{notification['key']}, status: #{notification['write'][0]}, headers: #{notification['write'][1]}, unlocked? #{notification['unlocked']}"
elsif notification['concurrent_request_response']
Rails.logger.warn "IdempotentRequest: Concurrent request detected with key #{notification['key']}"
end
end
# application.rb
config.middleware.use IdempotentRequest::Middleware,
header_key: 'X-Qonto-Idempotency-Key', # by default Idempotency-key
policy: IdempotentRequest::Policy,
callback: IdempotentRequest::RailsCallback,
storage: IdempotentRequest::RedisStorage.new(::Redis.current, expire_time: 1.day, namespace: 'idempotency_keys'),
conflict_response_status: 409
Custom class to decide whether the request should be idempotent.
See Example of integration for rails
Where the response will be stored. Can be any class that implements the following interface:
def read(key)
# read from a storage
end
def write(key, payload)
# write to a storage
end
Get notified when a client sends a request with the same idempotency key:
class RailsCallback
attr_reader :request
def initialize(request)
@request = request
end
def detected(key:)
Rails.logger.warn "IdempotentRequest request detected, key: #{key}"
end
end
Define http status code that should be returned when a client sends concurrent requests with the same idempotency key.
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/idempotent-request. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the Idempotent::Request project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.
To publish a new version to rubygems, update the version in lib/version.rb
, and merge.