one_more_time 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/Rakefile +17 -0
- data/bin/console +14 -0
- data/lib/one_more_time.rb +50 -0
- data/lib/one_more_time/idempotent_request.rb +58 -0
- data/lib/one_more_time/version.rb +3 -0
- data/lib/tasks/one_more_time_tasks.rake +4 -0
- metadata +113 -0
checksums.yaml
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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).
|
data/Rakefile
ADDED
@@ -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'
|
data/bin/console
ADDED
@@ -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
|
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: []
|