idempo 0.1.3 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +102 -0
- data/CHANGELOG.md +5 -1
- data/README.md +78 -20
- data/idempo.gemspec +1 -0
- data/lib/idempo/active_record_backend.rb +50 -8
- data/lib/idempo/concurrent_request_error_app.rb +15 -0
- data/lib/idempo/malformed_key_error_app.rb +13 -0
- data/lib/idempo/redis_backend.rb +1 -1
- data/lib/idempo/request_fingerprint.rb +15 -0
- data/lib/idempo/version.rb +1 -1
- data/lib/idempo.rb +23 -38
- metadata +20 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 96b24842e2d4d301bcf7a1470790a392fa1443a82ab2634e3aa81adbd173828c
|
4
|
+
data.tar.gz: 02243d85940b4e9f110fc6460eb925533d39ca62e331237b8e5ad4934f250703
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b931753fa35dc613bf54416010c0c12a29855dedf1d9076958e7ef60e7594b1d90d53deae18dc5e5c9de6468916175ca49761413a54054c0c0523af552c29604
|
7
|
+
data.tar.gz: d32efc792665bd2695b39f3b98280196d9046e2e5b8affee072023f5e438477f064a1010927bf99fd755cf657d85796f19c28c51ba0086b10645ba95e5e2b66b
|
@@ -0,0 +1,102 @@
|
|
1
|
+
name: CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
- push
|
5
|
+
- pull_request
|
6
|
+
|
7
|
+
env:
|
8
|
+
BUNDLE_PATH: vendor/bundle
|
9
|
+
|
10
|
+
services:
|
11
|
+
mysql:
|
12
|
+
image: mysql:5.7
|
13
|
+
env:
|
14
|
+
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
15
|
+
ports:
|
16
|
+
- 3306
|
17
|
+
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
18
|
+
redis:
|
19
|
+
image: redis
|
20
|
+
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
21
|
+
|
22
|
+
jobs:
|
23
|
+
lint:
|
24
|
+
name: Code Style
|
25
|
+
runs-on: ubuntu-18.04
|
26
|
+
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
27
|
+
strategy:
|
28
|
+
matrix:
|
29
|
+
ruby:
|
30
|
+
- '2.7'
|
31
|
+
steps:
|
32
|
+
- name: Checkout
|
33
|
+
uses: actions/checkout@v2
|
34
|
+
- name: Setup Ruby
|
35
|
+
uses: ruby/setup-ruby@v1
|
36
|
+
with:
|
37
|
+
ruby-version: ${{ matrix.ruby }}
|
38
|
+
- name: Gemfile Cache
|
39
|
+
uses: actions/cache@v2
|
40
|
+
with:
|
41
|
+
path: Gemfile.lock
|
42
|
+
key: ${{ runner.os }}-gemlock-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'idempo.gemspec') }}
|
43
|
+
restore-keys: |
|
44
|
+
${{ runner.os }}-gemlock-${{ matrix.ruby }}-
|
45
|
+
- name: Bundle Cache
|
46
|
+
id: cache-gems
|
47
|
+
uses: actions/cache@v2
|
48
|
+
with:
|
49
|
+
path: vendor/bundle
|
50
|
+
key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'Gemfile.lock', 'idempo.gemspec') }}
|
51
|
+
restore-keys: |
|
52
|
+
${{ runner.os }}-gems-${{ matrix.ruby }}-
|
53
|
+
${{ runner.os }}-gems-
|
54
|
+
- name: Bundle Install
|
55
|
+
if: steps.cache-gems.outputs.cache-hit != 'true'
|
56
|
+
run: bundle install --jobs 4 --retry 3
|
57
|
+
- name: Rubocop Cache
|
58
|
+
uses: actions/cache@v2
|
59
|
+
with:
|
60
|
+
path: ~/.cache/rubocop_cache
|
61
|
+
key: ${{ runner.os }}-rubocop-${{ hashFiles('.rubocop.yml') }}
|
62
|
+
restore-keys: |
|
63
|
+
${{ runner.os }}-rubocop-
|
64
|
+
- name: Rubocop
|
65
|
+
run: bundle exec rubocop
|
66
|
+
test:
|
67
|
+
name: Specs
|
68
|
+
runs-on: ubuntu-18.04
|
69
|
+
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
70
|
+
strategy:
|
71
|
+
matrix:
|
72
|
+
ruby:
|
73
|
+
- '2.6'
|
74
|
+
- '3.0'
|
75
|
+
steps:
|
76
|
+
- name: Checkout
|
77
|
+
uses: actions/checkout@v2
|
78
|
+
- name: Setup Ruby
|
79
|
+
uses: ruby/setup-ruby@v1
|
80
|
+
with:
|
81
|
+
ruby-version: ${{ matrix.ruby }}
|
82
|
+
- name: Gemfile Cache
|
83
|
+
uses: actions/cache@v2
|
84
|
+
with:
|
85
|
+
path: Gemfile.lock
|
86
|
+
key: ${{ runner.os }}-gemlock-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'idempo.gemspec') }}
|
87
|
+
restore-keys: |
|
88
|
+
${{ runner.os }}-gemlock-${{ matrix.ruby }}-
|
89
|
+
- name: Bundle Cache
|
90
|
+
id: cache-gems
|
91
|
+
uses: actions/cache@v2
|
92
|
+
with:
|
93
|
+
path: vendor/bundle
|
94
|
+
key: ${{ runner.os }}-gems-${{ matrix.ruby }}-${{ hashFiles('Gemfile', 'Gemfile.lock', 'idempo.gemspec') }}
|
95
|
+
restore-keys: |
|
96
|
+
${{ runner.os }}-gems-${{ matrix.ruby }}-
|
97
|
+
${{ runner.os }}-gems-
|
98
|
+
- name: Bundle Install
|
99
|
+
if: steps.cache-gems.outputs.cache-hit != 'true'
|
100
|
+
run: bundle install --jobs 4 --retry 3
|
101
|
+
- name: RSpec
|
102
|
+
run: bundle exec rspec
|
data/CHANGELOG.md
CHANGED
@@ -1,4 +1,8 @@
|
|
1
|
-
## [
|
1
|
+
## [0.2.0] - 2022-04-08
|
2
|
+
|
3
|
+
- Allow setting the global default TTL for the cached responses
|
4
|
+
- Allow customisation of the request key computation (so that the client can decide whether to include/exclude `Authorization` and the like)
|
5
|
+
- Extract the error response generating apps into separate modules, to make them easier to override
|
2
6
|
|
3
7
|
## [0.1.0] - 2021-10-14
|
4
8
|
|
data/README.md
CHANGED
@@ -5,22 +5,6 @@ application, and the response can be cached, Idempo will provide both a concurre
|
|
5
5
|
the idempotent response is already saved for this idempotency key and request fingerprint, the cached response is going to be served
|
6
6
|
instead of calling your application.
|
7
7
|
|
8
|
-
## Installation
|
9
|
-
|
10
|
-
Add this line to your application's Gemfile:
|
11
|
-
|
12
|
-
```ruby
|
13
|
-
gem 'idempo'
|
14
|
-
```
|
15
|
-
|
16
|
-
And then execute:
|
17
|
-
|
18
|
-
$ bundle install
|
19
|
-
|
20
|
-
Or install it yourself as:
|
21
|
-
|
22
|
-
$ gem install idempo
|
23
|
-
|
24
8
|
## Usage
|
25
9
|
|
26
10
|
Idempo supports a number of backends, we recommend using Redis if you have multiple application servers / dynos and MemoryBackend if you are only using one single Puma worker. To initialize with Redis as backend pass the `backend:` parameter when adding the middleware:
|
@@ -32,7 +16,7 @@ use Idempo, backend: Idempo::RedisBackend.new(Rails.application.config.redis_con
|
|
32
16
|
and to initialize with a memory store as backend:
|
33
17
|
|
34
18
|
```ruby
|
35
|
-
use Idempo
|
19
|
+
use Idempo
|
36
20
|
```
|
37
21
|
|
38
22
|
In principle, the following requests qualify to be cached used the idempotency key:
|
@@ -42,12 +26,86 @@ In principle, the following requests qualify to be cached used the idempotency k
|
|
42
26
|
|
43
27
|
The default time for storing the cache is 30 seconds from the moment the request has finished generating. The response is going to be buffered, then serialized using msgpack, then deflated. Idempo will not cache the response if its size cannot be known in advance, and if the size of the response body exceeds a reasonable size (4 MB is our limit for the time being) - this is to prevent your storage from filling up with very large responses.
|
44
28
|
|
45
|
-
## Controlling the behavior of Idempo
|
29
|
+
## Controlling the behavior of Idempo from your application
|
46
30
|
|
47
|
-
You can control the behavior of Idempo using special headers:
|
31
|
+
You can control the behavior of Idempo using special response headers:
|
48
32
|
|
49
33
|
* Set `X-Idempo-Policy` to `no-store` to disable retention of the response even though it otherwise could be cached
|
50
|
-
* Set `X-Idempo-Persist-For-Seconds` to decimal number of seconds to store your response
|
34
|
+
* Set `X-Idempo-Persist-For-Seconds` to a decimal number of seconds to store your response for. If your response contains time-sensitive data you might need to tweak the storage time.
|
35
|
+
|
36
|
+
Idempo supports a number of data stores (here they are called "backends") - `MemoryBackend`, `ActiveRecordBackend`, `RedisBackend`.
|
37
|
+
|
38
|
+
## Using memory for idempotency keys
|
39
|
+
|
40
|
+
If you run only one Puma on one server (so multiple threads but one process) the `MemoryBackend` will work fine for you.
|
41
|
+
|
42
|
+
* It uses a `Set` with a `Mutex` around it to store requests in progress
|
43
|
+
* It uses a sorted array for expiration and cached responses.
|
44
|
+
|
45
|
+
Needless to say, if your server terminates or restarts all the data goes dead with it. However
|
46
|
+
|
47
|
+
## Using your database for idempotency keys (via ActiveRecord)
|
48
|
+
|
49
|
+
The relational database you already have is a perfectly fine place to store idempotency key locks and responses. A requirement for that is that your database supports some form of advisory locking - both PostgreSQL and MySQL do. First you will need to create a table for the records. The table is going to be called `idempo_responses`, and you need to add a migration in your Rails project for it:
|
50
|
+
|
51
|
+
```bash
|
52
|
+
$ rails g migration add_idempo_responses
|
53
|
+
```
|
54
|
+
|
55
|
+
and then add a migration like this:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
class AddIdempoResponses < ActiveRecord::Migration[7.0]
|
59
|
+
def change
|
60
|
+
Idempo::ActiveRecordBackend.create_table(self)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
```
|
64
|
+
|
65
|
+
Then configure Idempo to use the backend (in your `application.rb`):
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
config.middleware.insert Idempo, backend: Idempo::ActiveRecordBackend.new
|
69
|
+
```
|
70
|
+
|
71
|
+
In your regular tasks (cron or Rake) you will want to add a call to delete old Idempo responses (there is an index on `expire_at`):
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
Idempo::ActiveRecordBackend.new.model.where('expire_at_ < ?', Time.now).delete_all
|
75
|
+
```
|
76
|
+
|
77
|
+
## Using Redis for idempotency keys
|
78
|
+
|
79
|
+
Redis is a near-perfect data store for idempotency keys, but it can have race conditions with locks if your application runs for too long or crashes very often. If you have Redis, initialize Idempo using the `RedisBackend`:
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
use Idempo, backend: Idempo::RedisBackend.new
|
83
|
+
```
|
84
|
+
|
85
|
+
If you have a configured Redis connection pool (and you should) - pass it to the initializer:
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
config.middleware.insert Idempo, backend: Idempo::RedisBackend.new(config.redis_connection_pool)
|
89
|
+
```
|
90
|
+
|
91
|
+
All data stored in Redis will have TTLs and will expire automatically.
|
92
|
+
|
93
|
+
|
94
|
+
## Installation
|
95
|
+
|
96
|
+
Add this line to your application's Gemfile:
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
gem 'idempo'
|
100
|
+
```
|
101
|
+
|
102
|
+
And then execute:
|
103
|
+
|
104
|
+
$ bundle install
|
105
|
+
|
106
|
+
Or install it yourself as:
|
107
|
+
|
108
|
+
$ gem install idempo
|
51
109
|
|
52
110
|
## Development
|
53
111
|
|
data/idempo.gemspec
CHANGED
@@ -46,6 +46,7 @@ Gem::Specification.new do |spec|
|
|
46
46
|
spec.add_development_dependency "rack-test"
|
47
47
|
spec.add_development_dependency "activerecord"
|
48
48
|
spec.add_development_dependency "mysql2"
|
49
|
+
spec.add_development_dependency "pg"
|
49
50
|
spec.add_development_dependency "wetransfer_style"
|
50
51
|
|
51
52
|
# For more information and examples about making a new gem, checkout our
|
@@ -3,7 +3,7 @@ class Idempo::ActiveRecordBackend
|
|
3
3
|
def self.create_table(via_migration)
|
4
4
|
via_migration.create_table 'idempo_responses', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci' do |t|
|
5
5
|
t.string :idempotent_request_key, index: true, unique: true, null: false
|
6
|
-
t.datetime :expire_at, index: true, null: false
|
6
|
+
t.datetime :expire_at, index: true, null: false # Needs an index for cleanup
|
7
7
|
t.binary :idempotent_response_payload, size: :medium
|
8
8
|
t.timestamps
|
9
9
|
end
|
@@ -25,6 +25,39 @@ class Idempo::ActiveRecordBackend
|
|
25
25
|
end
|
26
26
|
end
|
27
27
|
|
28
|
+
class PostgresLock
|
29
|
+
def acquire(conn, based_on_str)
|
30
|
+
acquisition_result = conn.select_value('SELECT pg_try_advisory_lock(%d)' % derive_lock_key(based_on_str))
|
31
|
+
[true, 't'].include?(acquisition_result)
|
32
|
+
end
|
33
|
+
|
34
|
+
def release(conn, based_on_str)
|
35
|
+
conn.select_value('SELECT pg_advisory_unlock(%d)' % derive_lock_key(based_on_str))
|
36
|
+
end
|
37
|
+
|
38
|
+
def derive_lock_key(from_str)
|
39
|
+
# The key must be a single bigint (signed long)
|
40
|
+
hash_bytes = Digest::SHA1.digest(from_str)
|
41
|
+
hash_bytes[0...8].unpack('l_').first
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class MysqlLock
|
46
|
+
def acquire(connection, based_on_str)
|
47
|
+
did_acquire = connection.select_value("SELECT GET_LOCK(%s, %d)" % [connection.quote(derive_lock_name(based_on_str)), 0])
|
48
|
+
did_acquire == 1
|
49
|
+
end
|
50
|
+
|
51
|
+
def release(connection, based_on_str)
|
52
|
+
connection.select_value("SELECT RELEASE_LOCK(%s)" % connection.quote(derive_lock_name(based_on_str)))
|
53
|
+
end
|
54
|
+
|
55
|
+
def derive_lock_name(from_str)
|
56
|
+
db_safe_key = Base64.strict_encode64(from_str)
|
57
|
+
"idempo_%s" % db_safe_key[0...57] # Note there is a limit of 64 bytes on the lock name
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
28
61
|
def initialize
|
29
62
|
require 'active_record'
|
30
63
|
end
|
@@ -37,18 +70,27 @@ class Idempo::ActiveRecordBackend
|
|
37
70
|
end
|
38
71
|
|
39
72
|
def with_idempotency_key(request_key)
|
40
|
-
db_safe_key =
|
41
|
-
|
42
|
-
lock_name = "idempo_%s" % db_safe_key[0..48]
|
43
|
-
quoted_lock_name = model.connection.quote(lock_name) # Note there is a limit of 64 bytes on the lock name
|
44
|
-
did_acquire = model.connection.select_value("SELECT GET_LOCK(%s, %d)" % [quoted_lock_name, 0])
|
73
|
+
db_safe_key = Digest::SHA1.base64digest(request_key)
|
74
|
+
lock = lock_implementation_for_connection(model.connection)
|
45
75
|
|
46
|
-
raise Idempo::ConcurrentRequest unless
|
76
|
+
raise Idempo::ConcurrentRequest unless lock.acquire(model.connection, request_key)
|
47
77
|
|
48
78
|
begin
|
49
79
|
yield(Store.new(db_safe_key, model))
|
50
80
|
ensure
|
51
|
-
model.connection
|
81
|
+
lock.release(model.connection, request_key)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def lock_implementation_for_connection(connection)
|
88
|
+
if connection.adapter_name =~ /^mysql2/i
|
89
|
+
MysqlLock.new
|
90
|
+
elsif connection.adapter_name =~ /^postgres/i
|
91
|
+
PostgresLock.new
|
92
|
+
else
|
93
|
+
raise "Unsupported database driver #{model.connection.adapter_name.downcase} - we don't know whether it supports advisory locks"
|
52
94
|
end
|
53
95
|
end
|
54
96
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
class Idempo::ConcurrentRequestErrorApp
|
4
|
+
RETRY_AFTER_SECONDS = 2.to_s
|
5
|
+
|
6
|
+
def self.call(env)
|
7
|
+
res = {
|
8
|
+
ok: false,
|
9
|
+
error: {
|
10
|
+
message: "Another request with this idempotency key is still in progress, please try again later"
|
11
|
+
}
|
12
|
+
}
|
13
|
+
[429, {'Retry-After' => RETRY_AFTER_SECONDS, 'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
class Idempo::MalformedKeyErrorApp
|
4
|
+
def self.call(env)
|
5
|
+
res = {
|
6
|
+
ok: false,
|
7
|
+
error: {
|
8
|
+
message: "The Idempotency-Key header provided was empty or malformed"
|
9
|
+
}
|
10
|
+
}
|
11
|
+
[400, {'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
|
12
|
+
end
|
13
|
+
end
|
data/lib/idempo/redis_backend.rb
CHANGED
@@ -64,7 +64,7 @@ class Idempo::RedisBackend
|
|
64
64
|
end
|
65
65
|
end
|
66
66
|
|
67
|
-
def initialize(redis_or_connection_pool)
|
67
|
+
def initialize(redis_or_connection_pool = Redis.new)
|
68
68
|
require 'redis'
|
69
69
|
require 'securerandom'
|
70
70
|
@redis_pool = redis_or_connection_pool.respond_to?(:with) ? redis_or_connection_pool : NullPool.new(redis_or_connection_pool)
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Idempo::RequestFingerprint
|
2
|
+
def self.call(idempotency_key, rack_request)
|
3
|
+
d = Digest::SHA256.new
|
4
|
+
d << idempotency_key << "\n"
|
5
|
+
d << rack_request.url << "\n"
|
6
|
+
d << rack_request.request_method << "\n"
|
7
|
+
d << rack_request.get_header('HTTP_AUTHORIZATION').to_s << "\n"
|
8
|
+
while chunk = rack_request.env['rack.input'].read(1024 * 65)
|
9
|
+
d << chunk
|
10
|
+
end
|
11
|
+
Base64.strict_encode64(d.digest)
|
12
|
+
ensure
|
13
|
+
rack_request.env['rack.input'].rewind
|
14
|
+
end
|
15
|
+
end
|
data/lib/idempo/version.rb
CHANGED
data/lib/idempo.rb
CHANGED
@@ -8,12 +8,15 @@ require 'json'
|
|
8
8
|
require 'measurometer'
|
9
9
|
|
10
10
|
require_relative "idempo/version"
|
11
|
+
require_relative "idempo/request_fingerprint"
|
11
12
|
require_relative "idempo/memory_backend"
|
12
13
|
require_relative "idempo/redis_backend"
|
13
14
|
require_relative "idempo/active_record_backend"
|
15
|
+
require_relative "idempo/malformed_key_error_app"
|
16
|
+
require_relative "idempo/concurrent_request_error_app"
|
14
17
|
|
15
18
|
class Idempo
|
16
|
-
|
19
|
+
DEFAULT_TTL_SECONDS = 30
|
17
20
|
SAVED_RESPONSE_BODY_SIZE_LIMIT = 4 * 1024 * 1024
|
18
21
|
|
19
22
|
class Error < StandardError; end
|
@@ -22,9 +25,13 @@ class Idempo
|
|
22
25
|
|
23
26
|
class MalformedIdempotencyKey < Error; end
|
24
27
|
|
25
|
-
def initialize(app, backend: MemoryBackend.new)
|
28
|
+
def initialize(app, backend: MemoryBackend.new, malformed_key_error_app: MalformedKeyErrorApp, compute_fingerprint_via: RequestFingerprint, concurrent_request_error_app: ConcurrentRequestErrorApp, persist_for_seconds: DEFAULT_TTL_SECONDS)
|
26
29
|
@backend = backend
|
27
30
|
@app = app
|
31
|
+
@concurrent_request_error_app = concurrent_request_error_app
|
32
|
+
@malformed_key_error_app = malformed_key_error_app
|
33
|
+
@fingerprint_calculator = compute_fingerprint_via
|
34
|
+
@persist_for_seconds = persist_for_seconds.to_i
|
28
35
|
end
|
29
36
|
|
30
37
|
def call(env)
|
@@ -33,11 +40,10 @@ class Idempo
|
|
33
40
|
return @app.call(env) unless idempotency_key_header = extract_idempotency_key_from(env)
|
34
41
|
|
35
42
|
# The RFC requires that the Idempotency-Key header value is enclosed in quotes
|
36
|
-
|
37
|
-
raise MalformedIdempotencyKey if
|
43
|
+
idempotency_key_header_value = unquote(idempotency_key_header)
|
44
|
+
raise MalformedIdempotencyKey if idempotency_key_header_value == ''
|
38
45
|
|
39
|
-
|
40
|
-
request_key = "#{idempotency_key_header}_#{fingerprint}"
|
46
|
+
request_key = @fingerprint_calculator.call(idempotency_key_header_value, req)
|
41
47
|
|
42
48
|
@backend.with_idempotency_key(request_key) do |store|
|
43
49
|
if stored_response = store.lookup
|
@@ -47,8 +53,8 @@ class Idempo
|
|
47
53
|
|
48
54
|
status, headers, body = @app.call(env)
|
49
55
|
|
56
|
+
expires_in_seconds = (headers.delete('X-Idempo-Persist-For-Seconds') || @persist_for_seconds).to_i
|
50
57
|
if response_may_be_persisted?(status, headers, body)
|
51
|
-
expires_in_seconds = (headers.delete('X-Idempo-Persist-For-Seconds') || DEFAULT_TTL).to_i
|
52
58
|
# Body is replaced with a cached version since a Rack response body is not rewindable
|
53
59
|
marshaled_response, body = serialize_response(status, headers, body)
|
54
60
|
store.store(data: marshaled_response, ttl: expires_in_seconds)
|
@@ -58,22 +64,11 @@ class Idempo
|
|
58
64
|
[status, headers, body]
|
59
65
|
end
|
60
66
|
rescue MalformedIdempotencyKey
|
61
|
-
|
62
|
-
|
63
|
-
error: {
|
64
|
-
message: "The Idempotency-Key header provided was empty"
|
65
|
-
}
|
66
|
-
}
|
67
|
-
[400, {'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
|
67
|
+
Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'malformed-idempotency-key')
|
68
|
+
@malformed_key_error_app.call(env)
|
68
69
|
rescue ConcurrentRequest
|
69
70
|
Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'conflict-concurrent-request')
|
70
|
-
|
71
|
-
ok: false,
|
72
|
-
error: {
|
73
|
-
message: "Another request with this idempotency key is still in progress, please try again later"
|
74
|
-
}
|
75
|
-
}
|
76
|
-
[429, {'Retry-After' => '2', 'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
|
71
|
+
@concurrent_request_error_app.call(env)
|
77
72
|
end
|
78
73
|
|
79
74
|
private
|
@@ -94,7 +89,7 @@ class Idempo
|
|
94
89
|
|
95
90
|
# Only keep headers which are strings
|
96
91
|
stringified_headers = headers.each_with_object({}) do |(header, value), filtered|
|
97
|
-
filtered[header] = value if value.is_a?(String)
|
92
|
+
filtered[header] = value if !header.start_with?('rack.') && value.is_a?(String)
|
98
93
|
end
|
99
94
|
|
100
95
|
message_packed_str = MessagePack.pack([status, stringified_headers, body_chunks])
|
@@ -120,8 +115,7 @@ class Idempo
|
|
120
115
|
|
121
116
|
return false unless body.is_a?(Array) # Arbitrary iterable of unknown size
|
122
117
|
|
123
|
-
|
124
|
-
precomputed_body_size <= SAVED_RESPONSE_BODY_SIZE_LIMIT
|
118
|
+
sum_of_string_bytesizes(body) <= SAVED_RESPONSE_BODY_SIZE_LIMIT
|
125
119
|
end
|
126
120
|
|
127
121
|
def status_may_be_persisted?(status)
|
@@ -137,19 +131,6 @@ class Idempo
|
|
137
131
|
end
|
138
132
|
end
|
139
133
|
|
140
|
-
def compute_request_fingerprint(req)
|
141
|
-
d = Digest::SHA256.new
|
142
|
-
d << req.url << "\n"
|
143
|
-
d << req.request_method << "\n"
|
144
|
-
d << req.get_header('HTTP_AUTHORIZATION').to_s << "\n"
|
145
|
-
while chunk = req.env['rack.input'].read(1024 * 65)
|
146
|
-
d << chunk
|
147
|
-
end
|
148
|
-
Base64.strict_encode64(d.digest)
|
149
|
-
ensure
|
150
|
-
req.env['rack.input'].rewind
|
151
|
-
end
|
152
|
-
|
153
134
|
def extract_idempotency_key_from(env)
|
154
135
|
env['HTTP_IDEMPOTENCY_KEY'] || env['HTTP_X_IDEMPOTENCY_KEY']
|
155
136
|
end
|
@@ -158,8 +139,12 @@ class Idempo
|
|
158
139
|
request.get? || request.head? || request.options?
|
159
140
|
end
|
160
141
|
|
142
|
+
def sum_of_string_bytesizes(in_array)
|
143
|
+
in_array.inject(0) { |sum, chunk| sum + chunk.bytesize }
|
144
|
+
end
|
145
|
+
|
161
146
|
def unquote(str)
|
162
|
-
# Do not use regular expressions so that we don't have to
|
147
|
+
# Do not use regular expressions so that we don't have to think about a catastrophic lookahead
|
163
148
|
double_quote = '"'
|
164
149
|
if str.start_with?(double_quote) && str.end_with?(double_quote)
|
165
150
|
str[1..-2]
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: idempo
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Julik Tarkhanov
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2022-04-08 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rack
|
@@ -137,6 +137,20 @@ dependencies:
|
|
137
137
|
- - ">="
|
138
138
|
- !ruby/object:Gem::Version
|
139
139
|
version: '0'
|
140
|
+
- !ruby/object:Gem::Dependency
|
141
|
+
name: pg
|
142
|
+
requirement: !ruby/object:Gem::Requirement
|
143
|
+
requirements:
|
144
|
+
- - ">="
|
145
|
+
- !ruby/object:Gem::Version
|
146
|
+
version: '0'
|
147
|
+
type: :development
|
148
|
+
prerelease: false
|
149
|
+
version_requirements: !ruby/object:Gem::Requirement
|
150
|
+
requirements:
|
151
|
+
- - ">="
|
152
|
+
- !ruby/object:Gem::Version
|
153
|
+
version: '0'
|
140
154
|
- !ruby/object:Gem::Dependency
|
141
155
|
name: wetransfer_style
|
142
156
|
requirement: !ruby/object:Gem::Requirement
|
@@ -159,6 +173,7 @@ executables: []
|
|
159
173
|
extensions: []
|
160
174
|
extra_rdoc_files: []
|
161
175
|
files:
|
176
|
+
- ".github/workflows/ci.yml"
|
162
177
|
- ".gitignore"
|
163
178
|
- ".rubocop.yml"
|
164
179
|
- CHANGELOG.md
|
@@ -171,8 +186,11 @@ files:
|
|
171
186
|
- idempo.gemspec
|
172
187
|
- lib/idempo.rb
|
173
188
|
- lib/idempo/active_record_backend.rb
|
189
|
+
- lib/idempo/concurrent_request_error_app.rb
|
190
|
+
- lib/idempo/malformed_key_error_app.rb
|
174
191
|
- lib/idempo/memory_backend.rb
|
175
192
|
- lib/idempo/redis_backend.rb
|
193
|
+
- lib/idempo/request_fingerprint.rb
|
176
194
|
- lib/idempo/response_store.rb
|
177
195
|
- lib/idempo/version.rb
|
178
196
|
homepage: https://github.com/julik/idempo
|