Skip to content

Rack middleware ensuring at most once requests for mutating endpoints.

License

Notifications You must be signed in to change notification settings

qonto/idempotent-request

Repository files navigation

Gem Version CI Status

Idempotent Request

Rack middleware ensuring at most once requests for mutating endpoints.

Installation

Add this line to your application's Gemfile:

gem 'idempotent-request'

And then execute:

$ bundle

Or install it yourself as:

$ gem install idempotent-request

How it works

  1. Front-end generates a unique key then a user goes to a specific route (for example, transfer page).
  2. When user clicks "Submit" button, the key is sent in the header idempotency-key and back-end stores server response into redis.
  3. 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.
  4. Once the user leaves or refreshes the page, front-end should re-generate the key.

Configuration

# 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

Example of integration for rails

# 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

Use ActiveSupport::Notifications to read events

# 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

Custom options

# 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

Policy

Custom class to decide whether the request should be idempotent.

See Example of integration for rails

Storage

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

Callback

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

Conflict response status

Define http status code that should be returned when a client sends concurrent requests with the same idempotency key.

Contributing

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.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Idempotent::Request project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Releasing

To publish a new version to rubygems, update the version in lib/version.rb, and merge.