Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/qonto/idempotent-request

Rack middleware ensuring at most once requests for mutating endpoints.
https://github.com/qonto/idempotent-request

idempotent middleware request ruby

Last synced: about 2 months ago
JSON representation

Rack middleware ensuring at most once requests for mutating endpoints.

Awesome Lists containing this project

README

        

![Gem Version](https://badge.fury.io/rb/idempotent-request.svg) ![CI Status](https://github.com/qonto/idempotent-request/actions/workflows/tests.yml/badge.svg)

# Idempotent Request

Rack middleware ensuring at most once requests for mutating endpoints.

## Installation

Add this line to your application's Gemfile:

```ruby
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
```ruby
# 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:

```ruby
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

```ruby
# 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 },
]
```

```ruby
# 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

```ruby
# 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

```ruby
# 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:

```ruby
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:

```ruby
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](http://contributor-covenant.org) code of conduct.

## License

The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).

## 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](https://github.com/[USERNAME]/idempotent-request/blob/master/CODE_OF_CONDUCT.md).

## Releasing

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