idempotent-request 0.1.1 → 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
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.