one_more_time 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 247918c0b167bd95918b841bf7b35cf1f5d15350dbc834ca7ad3cb91fda99ca6
4
+ data.tar.gz: 809be36d13054cf346431fd204a2e86702f0cbcee8106f14b958730796c4052a
5
+ SHA512:
6
+ metadata.gz: 0a9796d7a4c64205aa0e0981043ce1aef31e99e5a0add3f1b5f74c2dd3cd51b36e2182bd388a52d0c515f6583061480ba2ddf11820aa3f02f26974323b531de4
7
+ data.tar.gz: ad8f0a600eb4e4234867e448c6f80957c46492f8e7e1709dd31fc4c887e4ae29e89fa00ce28e350db7148bd946a180ed91f6948aa208a824eafd936ee3c5a61f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 Andrew Cross
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,151 @@
1
+ # One More Time
2
+
3
+ A simple gem to help make your API idempotent.
4
+ Because it should always be safe to call your API... _one more time_.
5
+
6
+ * [Overview](#overview)
7
+ * [Installation](#installation)
8
+ * [Usage](#usage)
9
+ * [Contributing](#contributing)
10
+ * [License](#license)
11
+
12
+ ## Overview
13
+
14
+ As a library for idempotency, One More Time serves two main purposes:
15
+ - Saving and returning the previous response to a repeated request
16
+ - Ensuring side effects that should only happen once (e.g. paying money) in fact only happen once
17
+
18
+ To accomplish these, this gem provides a single ActiveRecord model called `IdempotentRequest` with some additional methods added. You call those methods at specific points in the lifecycle of your request, and idempotency is guaranteed for you.
19
+
20
+ Generic solutions (i.e. drop-in Rack plugins) for idempotency do exist, however they can't handle requests that fail during processing very well because they don't know _when_ a request becomes unsafe to retry. If there's nothing you're afraid to retry when your request is left in an undefined state, a generic solution will suffice.
21
+
22
+ 🚨 **Note**: One More Time is intended as middleware and does not know about the network procotol being used; it only provides dumb storage for request and response data. Thus you will need to tell the gem how to store incoming requests, as well as how to convert stored responses into actual responses at the network level. The goal would be to write this glue code once for a given Ruby application framework (e.g. Rails controllers, Gruf, Sinatra) and then re-use it thereafter.
23
+
24
+ ## Installation
25
+
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem 'one_more_time'
30
+ ```
31
+
32
+ And then execute:
33
+
34
+ $ bundle
35
+
36
+ Or install it yourself as:
37
+
38
+ $ gem install one_more_time
39
+
40
+ Then create a table in your database named `idempotent_requests` with, at minimum, the following columns (a generator for this is in the TODO list):
41
+
42
+ | Name | ActiveRecord Type |
43
+ |-----------------|-------------
44
+ | idempotency_key | string / text (with UNIQUE constraint)
45
+ | locked_at | datetime
46
+ | request_body | (any)
47
+ | request_path | (any)
48
+ | response_code | (any)
49
+ | response_body | (any)
50
+
51
+ 🚨 **Note**: The idempotency_key column MUST have a unique constraint for the gem to work.
52
+
53
+ ## Usage
54
+
55
+ In order to guarantee idempotency, One More Time assumes a request can be divided into three phases:
56
+ 1. The incoming request is stored in the local database and is validated. Any read-only queries or service calls are made.
57
+ 2. State is changed in an external service (e.g. an order is submitted, a payment is made)
58
+ 3. The results of step 2 are stored in the local database.
59
+
60
+ ☝️ Note: For more in-depth reading, this is the same pattern enforced by AirBnB's idempotency middleware (https://medium.com/airbnb-engineering/avoiding-double-payments-in-a-distributed-payments-system-2981f6b070bb).
61
+
62
+ Let's see how this looks in code, for example in a rails controller:
63
+ ```ruby
64
+ # We begin by calling OneMoreTime.start_request!, which either creates or finds a record
65
+ # using the given idempotency_key. The record (for now an ActiveRecord model but don't rely
66
+ # on that) has methods to help orchestrate your request.
67
+ # When first created, the record is in a "locked" state so no other server process will be
68
+ # able to work on the same request. If we try to access a locked record here, a
69
+ # RequestInProgressError will be raised.
70
+ idempotent_request = OneMoreTime.start_request!(
71
+ # This value is supplied by the client, who decides the scope of idempotency
72
+ idempotency_key: request.headers["Idempotency-Key"],
73
+ # If supplied, these values will be used to verify that the incoming request data matches
74
+ # the stored data (when a record with the given idempotency_key already exists).
75
+ # If there is a mismatch, a RequestMismatchError will be raised.
76
+ request_path: "#{request.method} #{request.path}",
77
+ request_body: request.raw_post,
78
+ )
79
+
80
+ # Set a callback to specify how to convert a successful result into response data stored
81
+ # on the record
82
+ idempotent_request.success_attributes do |result|
83
+ {
84
+ response_code: 200,
85
+ response_body: result.to_json,
86
+ }
87
+ end
88
+
89
+ # Similarly, convert an exception that has been raised into a stored response
90
+ idempotent_request.failure_attributes do |exception|
91
+ {
92
+ response_code: 500,
93
+ response_body: { error: exception.message }.to_json,
94
+ }
95
+ end
96
+ ```
97
+
98
+ Everything up to this point will likely occur only once, as framework-level code in your app. Individual endpoint implementations should be provided with the `idempotent_request` object and only need to use the following pattern:
99
+
100
+ ```ruby
101
+ # Wrap your request in a block sent to the execute method. If the idempotent_request already
102
+ # has a stored response, the block will be skipped entirely.
103
+ idempotent_request.execute do
104
+ # Validate the request as needed.
105
+ # Because we are inside the execute block, raising an error will automatically unlock the
106
+ # request and NOT store a response, so this block can be retried by the next attempt.
107
+ raise ActionController::BadRequest unless params[:widget_name].present?
108
+
109
+ # Make an external, non-idempotent service call
110
+ begin
111
+ widget = ExternalService.purchase_widget(params[:widget_name])
112
+ rescue ExternalService::ConnectionLostError => exception
113
+ # We sent data to the external service but don't know whether it was fully processed.
114
+ # If a widget is something we can't afford to accidentally create twice, we need to
115
+ # give up and store an error response on this request so it can't be retried.
116
+ # This will invoke the failure_attributes callback and raise out of the execute block.
117
+ idempotent_request.failure!(exception: exception)
118
+ end
119
+
120
+ # Call the success! method with a block. This block is automatically run in a transaction
121
+ # and should contain any code needed to persist the results of your external service call.
122
+ # Any failure within this block will internally call failure! on the request - we've
123
+ # failed to store a record of the widget, but we DID purchase it, so we can't allow a retry.
124
+ idempotent_request.success do
125
+ # The return value of this block is what gets passed to the success_attributes callback
126
+ Widget.create!(widget_id: widget.id)
127
+ end
128
+ end
129
+
130
+ # Render the stored response, which we are guaranteed to have if we make it here
131
+ render json: idempotent_request.response_body, status: idempotent_request.response_code
132
+
133
+ # And that's it!
134
+ ```
135
+
136
+ ## Contributing
137
+
138
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Freshly/one_more_time.
139
+
140
+ Todos/Enhancements:
141
+ - Add a rails generator to create a migration for the idempotent_requests table.
142
+ - Add example integations for different app frameworks.
143
+ - Rails
144
+ - Gruf
145
+ - Sinatra
146
+ - Possibly add another yielding method that calls `failure!` by default for any exception. When using this method, instead of rescuing errors that are _not_ retryable and calling `failure!` yourself, you would explicitly rescue exceptions that _are_ retryable.
147
+ - Look into supporting recovery points/multiple transactions per request.
148
+
149
+ ## License
150
+
151
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,17 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'OneMoreTime'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ require 'bundler/gem_tasks'
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "one_more_time"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "one_more_time/idempotent_request"
5
+
6
+ module OneMoreTime
7
+ class Error < StandardError; end
8
+ class RequestInProgressError < Error; end
9
+ class RequestMismatchError < Error; end
10
+
11
+ class << self
12
+ def start_request!(idempotency_key:, request_path: nil, request_body: nil)
13
+ IdempotentRequest.create!(
14
+ idempotency_key: idempotency_key,
15
+ locked_at: Time.current,
16
+ request_path: request_path,
17
+ request_body: request_body,
18
+ )
19
+ rescue ActiveRecord::RecordNotUnique
20
+ # Our UNIQUE constraint was violated, so a request with the given idempotency
21
+ # key already exists. Use the highest transaction isolation level to atomically
22
+ # load that request record and mark it as "locked".
23
+ # Similar to Rails create_or_find_by, the race condition here is if another
24
+ # client deleted the request record exactly at this point. For this specific
25
+ # model there is basically no risk of that happening.
26
+ serializable_transaction do
27
+ existing_request = IdempotentRequest.find_by(idempotency_key: idempotency_key, locked_at: nil)
28
+ validate_incoming_request!(existing_request, request_path, request_body)
29
+ existing_request.update!(locked_at: Time.current) unless existing_request.finished?
30
+ existing_request
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def validate_incoming_request!(existing_request, request_path, request_body)
37
+ raise RequestInProgressError if existing_request.blank?
38
+ raise RequestMismatchError if request_path.present? && request_path != existing_request.request_path
39
+ raise RequestMismatchError if request_body.present? && request_body != existing_request.request_body
40
+ end
41
+
42
+ def serializable_transaction
43
+ ActiveRecord::Base.transaction(isolation: :serializable) do
44
+ yield
45
+ end
46
+ rescue ActiveRecord::SerializationFailure
47
+ raise RequestInProgressError
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OneMoreTime
4
+ class IdempotentRequest < ActiveRecord::Base
5
+ def success_attributes(&block)
6
+ @success_attributes_block = block
7
+ end
8
+
9
+ def failure_attributes(&block)
10
+ @failure_attributes_block = block
11
+ end
12
+
13
+ def execute
14
+ # No-op the request if we already have a saved response.
15
+ return if finished?
16
+
17
+ begin
18
+ yield if block_given?
19
+ rescue PermanentError
20
+ # Something has called .failure!, so we assume there is now a saved response
21
+ # and can just no-op
22
+ rescue StandardError
23
+ update_and_unlock
24
+ raise
25
+ end
26
+ # TODO: raise unless finished?
27
+ end
28
+
29
+ def success
30
+ ActiveRecord::Base.transaction do
31
+ result = yield if block_given?
32
+ attrs = @success_attributes_block&.call(result) || {}
33
+ update_and_unlock(attrs)
34
+ end
35
+ rescue StandardError => exception
36
+ failure!(exception: exception)
37
+ end
38
+
39
+ def failure!(exception: nil, response_code: nil, response_body: nil)
40
+ attrs = (exception.present? && @failure_attributes_block&.call(exception)) || {}
41
+ attrs.merge!({response_code: response_code, response_body: response_body}.compact)
42
+ update_and_unlock(attrs)
43
+ raise PermanentError
44
+ end
45
+
46
+ def finished?
47
+ response_code.present? || response_body.present?
48
+ end
49
+
50
+ private
51
+
52
+ class PermanentError < StandardError; end
53
+
54
+ def update_and_unlock(attrs={})
55
+ update!({ locked_at: nil }.merge(attrs.slice(:response_code, :response_body)))
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,3 @@
1
+ module OneMoreTime
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :one_more_time do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: one_more_time
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Cross
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-06-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.2
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 6.0.2.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: 6.0.2
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 6.0.2.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: rspec
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: sqlite3
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: timecop
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ description: Use your database to store previous responses and guarantee safe retries.
76
+ email:
77
+ - andrew.cross@freshly.com
78
+ executables: []
79
+ extensions: []
80
+ extra_rdoc_files: []
81
+ files:
82
+ - LICENSE
83
+ - README.md
84
+ - Rakefile
85
+ - bin/console
86
+ - lib/one_more_time.rb
87
+ - lib/one_more_time/idempotent_request.rb
88
+ - lib/one_more_time/version.rb
89
+ - lib/tasks/one_more_time_tasks.rake
90
+ homepage: https://github.com/Freshly/one_more_time
91
+ licenses:
92
+ - MIT
93
+ metadata: {}
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.0.3
110
+ signing_key:
111
+ specification_version: 4
112
+ summary: A simple gem to help make your API idempotent
113
+ test_files: []