idempotent-request 0.1.1 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d7a529e3aef3790df0207b93cc2568d0796c36d01193a8309f24f815eb78a43
4
- data.tar.gz: 6a70bedce0b0a3bd2456ed504cd2c1a266776ca50a5d7da1e89c441a92bb3a75
3
+ metadata.gz: cee83a3ed2329dd90ca0e12a8720c46225e90e2334a0a1d614cd911dbcfe02b4
4
+ data.tar.gz: 4d48b2dc61f9c7561788676d0cb9c3ada34edd1ff244f80e86c796236be7c819
5
5
  SHA512:
6
- metadata.gz: b29abb27b091e57f83bb8f2cf241d0083653c3e4ce93138f486fd19448e64723419d78923fb662aaa21dec938b06f46cd76c353d444b59a134aa6b2919ec977c
7
- data.tar.gz: 08b45d6a1db58c78274fe099eec5bd5af8f1549c543598b539d3004f97802c87763d8abd54e1bd8b003a75669426be4c2bd96844543e97a985a9fa7a90ec9c3f
6
+ metadata.gz: d0d61d258911181604c231b43e8711c09be4d74be10d7ed7d278fc28a438a1ec9f89a4a6e65cfdd0a0ae404f126058159ed55874bfcb71251514c1c27293ee01
7
+ data.tar.gz: 55308b0cf3a655fdb9f0c0d192e87f248ebfd2a966447df893000de29f5e5b6531816890f3b90a47c6087a09ffad0df58cb5ba04e07c3ba8262b068ecadf85d3
@@ -1,7 +1,10 @@
1
1
  sudo: false
2
2
  language: ruby
3
3
  rvm:
4
- - 2.4.2
4
+ - 2.4.10
5
+ - 2.5.8
6
+ - 2.6.6
7
+ - 2.7.1
5
8
  deploy:
6
9
  skip_cleanup: true
7
10
  provider: rubygems
@@ -1,5 +1,23 @@
1
1
  # Idempotent Request Changelog #
2
2
 
3
+ ## v0.1.5 ##
4
+
5
+ * use ActiveSupport::Notifications to instrument events
6
+ * fix an issue when getting an exception inside application would not delete lock, so client could receive 429 after 500
7
+
8
+ ## v0.1.4 ##
9
+
10
+ * Fix an issue, when http response from backend != 200..226 caused lock to be taken
11
+
12
+ ## v0.1.3 ##
13
+
14
+ * Fix an issue, when concurrent requests sent to an endpoint won't be protected by idempotency until the 1st request is finished
15
+
16
+ ## v0.1.2 ##
17
+
18
+ * Fix rack response
19
+ * Disuse SETNX (@espadrine)
20
+
3
21
  ## v0.1.1 ##
4
22
 
5
- * Add caching for successful responses (@gomayonqui)
23
+ * Add caching for successful responses (@gomayonqui)
data/README.md CHANGED
@@ -84,6 +84,23 @@ module IdempotentRequest
84
84
  end
85
85
  ```
86
86
 
87
+
88
+ ### Use ActiveSupport::Notifications to read events
89
+
90
+ ```ruby
91
+ # config/initializers/idempotent_request.rb
92
+ ActiveSupport::Notifications.subscribe('idempotent.request') do |name, start, finish, request_id, payload|
93
+ notification = payload[:request].env['idempotent.request']
94
+ if notification['read']
95
+ Rails.logger.info "IdempotentRequest: Hit cached response from key #{notification['key']}, response: #{notification['read']}"
96
+ elsif notification['write']
97
+ Rails.logger.info "IdempotentRequest: Write: key #{notification['key']}, status: #{notification['write'][0]}, headers: #{notification['write'][1]}, unlocked? #{notification['unlocked']}"
98
+ elsif notification['concurrent_request_response']
99
+ Rails.logger.warn "IdempotentRequest: Concurrent request detected with key #{notification['key']}"
100
+ end
101
+ end
102
+ ```
103
+
87
104
  ## Custom options
88
105
 
89
106
  ```ruby
@@ -92,7 +109,8 @@ config.middleware.use IdempotentRequest::Middleware,
92
109
  header_key: 'X-Qonto-Idempotency-Key', # by default Idempotency-key
93
110
  policy: IdempotentRequest::Policy,
94
111
  callback: IdempotentRequest::RailsCallback,
95
- storage: IdempotentRequest::RedisStorage.new(::Redis.current, expire_time: 1.day, namespace: 'idempotency_keys')
112
+ storage: IdempotentRequest::RedisStorage.new(::Redis.current, expire_time: 1.day, namespace: 'idempotency_keys'),
113
+ conflict_response_status: 409
96
114
  ```
97
115
 
98
116
  ### Policy
@@ -117,7 +135,7 @@ end
117
135
 
118
136
  ### Callback
119
137
 
120
- Get notified when the client sends a request with the same idempotency key:
138
+ Get notified when a client sends a request with the same idempotency key:
121
139
 
122
140
  ```ruby
123
141
  class RailsCallback
@@ -133,6 +151,10 @@ class RailsCallback
133
151
  end
134
152
  ```
135
153
 
154
+ ### Conflict response status
155
+
156
+ Define http status code that should be returned when a client sends concurrent requests with the same idempotency key.
157
+
136
158
  ## Contributing
137
159
 
138
160
  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.
@@ -23,9 +23,9 @@ Gem::Specification.new do |spec|
23
23
  spec.add_dependency 'rack', '~> 2.0'
24
24
  spec.add_dependency 'oj', '~> 3.0'
25
25
 
26
- spec.add_development_dependency 'bundler', '~> 1.15'
27
- spec.add_development_dependency 'rake', '~> 10.0'
26
+ spec.add_development_dependency 'bundler'
27
+ spec.add_development_dependency 'rake', '>= 12.3.3'
28
28
  spec.add_development_dependency 'rspec', '~> 3.0'
29
29
  spec.add_development_dependency 'fakeredis', '~> 0.6'
30
- spec.add_development_dependency 'pry', '~> 0.11'
30
+ spec.add_development_dependency 'byebug', '~> 10.0'
31
31
  end
@@ -4,6 +4,8 @@ module IdempotentRequest
4
4
  @app = app
5
5
  @config = config
6
6
  @policy = config.fetch(:policy)
7
+ @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
8
+ @conflict_response_status = config.fetch(:conflict_response_status, 429)
7
9
  end
8
10
 
9
11
  def call(env)
@@ -13,14 +15,45 @@ module IdempotentRequest
13
15
 
14
16
  def process(env)
15
17
  set_request(env)
18
+ request.env['idempotent.request'] = {}
16
19
  return app.call(request.env) unless process?
17
- storage = RequestManager.new(request, config)
18
- storage.read || storage.write(*app.call(request.env))
20
+ request.env['idempotent.request']['key'] = request.key
21
+ response = read_idempotent_request || write_idempotent_request || concurrent_request_response
22
+ instrument(request)
23
+ response
19
24
  end
20
25
 
21
26
  private
22
27
 
23
- attr_reader :app, :env, :config, :request, :policy
28
+ def storage
29
+ @storage ||= RequestManager.new(request, config)
30
+ end
31
+
32
+ def read_idempotent_request
33
+ request.env['idempotent.request']['read'] = storage.read
34
+ end
35
+
36
+ def write_idempotent_request
37
+ return unless storage.lock
38
+ begin
39
+ result = app.call(request.env)
40
+ request.env['idempotent.request']['write'] = result
41
+ storage.write(*result)
42
+ ensure
43
+ request.env['idempotent.request']['unlocked'] = storage.unlock
44
+ result
45
+ end
46
+ end
47
+
48
+ def concurrent_request_response
49
+ status = @conflict_response_status
50
+ headers = { 'Content-Type' => 'application/json' }
51
+ body = [ Oj.dump('error' => 'Concurrent requests detected') ]
52
+ request.env['idempotent.request']['concurrent_request_response'] = true
53
+ Rack::Response.new(body, status, headers).finish
54
+ end
55
+
56
+ attr_reader :app, :env, :config, :request, :policy, :notifier
24
57
 
25
58
  def process?
26
59
  !request.key.to_s.empty? && should_be_idempotent?
@@ -31,6 +64,10 @@ module IdempotentRequest
31
64
  policy.new(request).should?
32
65
  end
33
66
 
67
+ def instrument(request)
68
+ notifier.instrument('idempotent.request', request: request) if notifier
69
+ end
70
+
34
71
  def set_request(env)
35
72
  @env = env
36
73
  @request ||= Request.new(env, config)
@@ -8,19 +8,41 @@ module IdempotentRequest
8
8
  @expire_time = config[:expire_time]
9
9
  end
10
10
 
11
+ def lock(key)
12
+ setnx_with_expiration(lock_key(key), Time.now.to_f)
13
+ end
14
+
15
+ def unlock(key)
16
+ redis.del(lock_key(key))
17
+ end
18
+
11
19
  def read(key)
12
20
  redis.get(namespaced_key(key))
13
21
  end
14
22
 
15
23
  def write(key, payload)
16
- redis.setnx(namespaced_key(key), payload)
17
- redis.expire(namespaced_key(key), expire_time.to_i) if expire_time.to_i > 0
24
+ setnx_with_expiration(namespaced_key(key), payload)
18
25
  end
19
26
 
20
27
  private
21
28
 
22
- def namespaced_key(idempotency_key)
23
- [namespace, idempotency_key.strip]
29
+ def setnx_with_expiration(key, data)
30
+ redis.set(
31
+ key,
32
+ data,
33
+ {}.tap do |options|
34
+ options[:nx] = true
35
+ options[:ex] = expire_time.to_i if expire_time.to_i > 0
36
+ end
37
+ )
38
+ end
39
+
40
+ def lock_key(key)
41
+ namespaced_key("lock:#{key}")
42
+ end
43
+
44
+ def namespaced_key(key)
45
+ [namespace, key.strip]
24
46
  .compact
25
47
  .join(':')
26
48
  .downcase
@@ -22,10 +22,9 @@ module IdempotentRequest
22
22
  private
23
23
 
24
24
  def header_name
25
- key = @header_name
26
- .to_s
27
- .upcase
28
- .gsub('-', '_')
25
+ key = @header_name.to_s
26
+ .upcase
27
+ .tr('-', '_')
29
28
 
30
29
  key.start_with?('HTTP_') ? key : "HTTP_#{key}"
31
30
  end
@@ -8,6 +8,14 @@ module IdempotentRequest
8
8
  @callback = config[:callback]
9
9
  end
10
10
 
11
+ def lock
12
+ storage.lock(key)
13
+ end
14
+
15
+ def unlock
16
+ storage.unlock(key)
17
+ end
18
+
11
19
  def read
12
20
  status, headers, response = parse_data(storage.read(key)).values
13
21
 
@@ -19,8 +27,11 @@ module IdempotentRequest
19
27
  def write(*data)
20
28
  status, headers, response = data
21
29
  response = response.body if response.respond_to?(:body)
22
- return data unless (200..226).include?(status)
23
- storage.write(key, payload(status, headers, response))
30
+
31
+ if (200..226).cover?(status)
32
+ storage.write(key, payload(status, headers, response))
33
+ end
34
+
24
35
  data
25
36
  end
26
37
 
@@ -33,17 +44,15 @@ module IdempotentRequest
33
44
  end
34
45
 
35
46
  def payload(status, headers, response)
36
- Oj.dump({
37
- status: status,
38
- headers: headers.to_h,
39
- response: response
40
- })
47
+ Oj.dump(status: status,
48
+ headers: headers.to_h,
49
+ response: Array(response))
41
50
  end
42
51
 
43
- def run_callback(action, args)
52
+ def run_callback(action, **args)
44
53
  return unless @callback
45
54
 
46
- @callback.new(request).send(action, args)
55
+ @callback.new(request).send(action, **args)
47
56
  end
48
57
 
49
58
  def key
@@ -1,3 +1,3 @@
1
1
  module IdempotentRequest
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.6"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idempotent-request
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmytro Zakharov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-02-19 00:00:00.000000000 Z
11
+ date: 2020-11-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -42,30 +42,30 @@ dependencies:
42
42
  name: bundler
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - "~>"
45
+ - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: '1.15'
47
+ version: '0'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - "~>"
52
+ - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: '1.15'
54
+ version: '0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - "~>"
59
+ - - ">="
60
60
  - !ruby/object:Gem::Version
61
- version: '10.0'
61
+ version: 12.3.3
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '10.0'
68
+ version: 12.3.3
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rspec
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -95,19 +95,19 @@ dependencies:
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0.6'
97
97
  - !ruby/object:Gem::Dependency
98
- name: pry
98
+ name: byebug
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '0.11'
103
+ version: '10.0'
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '0.11'
110
+ version: '10.0'
111
111
  description:
112
112
  email:
113
113
  - dmytro@qonto.eu
@@ -152,8 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
152
  - !ruby/object:Gem::Version
153
153
  version: '0'
154
154
  requirements: []
155
- rubyforge_project:
156
- rubygems_version: 2.7.6
155
+ rubygems_version: 3.0.8
157
156
  signing_key:
158
157
  specification_version: 4
159
158
  summary: Rack middleware ensuring at most once requests for mutating endpoints.