idempo 1.0.0 → 1.2.1

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: 43b22b0723a04b9388d0c5d2142220c4eac8dbe97055ae9feac075522d022c14
4
- data.tar.gz: 8f33ce3508f887f4e364abe12e57c20caec7b6214312d0258637d6dc928bf95a
3
+ metadata.gz: 79351972a8246031e7301fcadb2d3721fa99300edffc00a3cb1a2e5fa828c68b
4
+ data.tar.gz: 27bb14ea7cfe6a10de6367cd725a549a0e26ccec289ca6bdc192752df39c60db
5
5
  SHA512:
6
- metadata.gz: e655619f00ad736e90114a4e7882f88c9d96113660af6c0b57990bf145a4a938713e50ab77c50e4cd0d07e85cb246d7be201a9308e8c921300bc15b3c5536d7e
7
- data.tar.gz: 9dd5a0a0cd091da4bf78e254cae840362fd5c5ca99b4cdac38c5f1d5c5811d870e234fe5ab64f53eec6c9c9463a80b2e3fa8c7d5914d4a013a3040cce73874ac
6
+ metadata.gz: e7cab37f569d2688a83b0d2c25c8f73b720c3fbc294ab6f2cd70686167f8c8dfb1864c738a33ffa28fec152f346e409cc0088be2d9a907974ac8c5370a9469fb
7
+ data.tar.gz: f2ad3d16e636499ce989c9093ad0c7144fb9d22fe89d63939045fbe9df593595ce349d4e9ae25cb70898020bc1e66ea0307889cd7b1eb3060e758675219e5add
@@ -7,22 +7,10 @@ on:
7
7
  env:
8
8
  BUNDLE_PATH: vendor/bundle
9
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
10
  jobs:
23
11
  lint:
24
12
  name: Code Style
25
- runs-on: ubuntu-18.04
13
+ runs-on: ubuntu-22.04
26
14
  if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
27
15
  strategy:
28
16
  matrix:
@@ -30,73 +18,63 @@ jobs:
30
18
  - '2.7'
31
19
  steps:
32
20
  - name: Checkout
33
- uses: actions/checkout@v2
21
+ uses: actions/checkout@v4
34
22
  - name: Setup Ruby
35
23
  uses: ruby/setup-ruby@v1
36
24
  with:
37
25
  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
26
+ bundler-cache: true
57
27
  - name: Rubocop Cache
58
- uses: actions/cache@v2
28
+ uses: actions/cache@v3
59
29
  with:
60
30
  path: ~/.cache/rubocop_cache
61
31
  key: ${{ runner.os }}-rubocop-${{ hashFiles('.rubocop.yml') }}
62
32
  restore-keys: |
63
33
  ${{ runner.os }}-rubocop-
64
- - name: Rubocop
65
- run: bundle exec rubocop
34
+ - name: Standard (Lint)
35
+ run: bundle exec rake standard
66
36
  test:
67
37
  name: Specs
68
- runs-on: ubuntu-18.04
38
+ runs-on: ubuntu-22.04
69
39
  if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
70
40
  strategy:
71
41
  matrix:
72
42
  ruby:
73
- - '2.6'
74
- - '3.0'
43
+ - '2.7'
44
+ - '3.2'
45
+ services:
46
+ mysql:
47
+ image: mysql:5.7
48
+ env:
49
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
50
+ ports:
51
+ - 3306:3306
52
+ options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
53
+ postgres:
54
+ image: postgres
55
+ env:
56
+ POSTGRES_PASSWORD: postgres
57
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
58
+ ports:
59
+ - 5432:5432
60
+ redis:
61
+ image: redis
62
+ options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
63
+ ports:
64
+ - 6379:6379
75
65
  steps:
76
66
  - name: Checkout
77
- uses: actions/checkout@v2
67
+ uses: actions/checkout@v4
78
68
  - name: Setup Ruby
79
69
  uses: ruby/setup-ruby@v1
80
70
  with:
81
71
  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
72
+ bundler-cache: true
101
73
  - name: RSpec
102
74
  run: bundle exec rspec
75
+ env:
76
+ MYSQL_HOST: 127.0.0.1
77
+ MYSQL_PORT: 3306
78
+ PGHOST: localhost
79
+ PGUSER: postgres
80
+ PGPASSWORD: postgres
data/.rubocop.yml CHANGED
@@ -1,2 +1,5 @@
1
1
  inherit_gem:
2
2
  wetransfer_style: ruby/default.yml
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 2.7
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 2.7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [1.2.1] - 2024-02-22
2
+
3
+ - Use autoloading for internal modules. A user using Redis does not have to load the ActiveRecord storage backend, for example
4
+ - Ensure that the original Rack response body receives a `close` when reading out for caching
5
+
6
+ ## [1.2.0] - 2024-02-22
7
+
8
+ - Use memory locking in addition to DB locking in `ActiveRecordBackend`
9
+
10
+ ## [1.1.0] - 2024-02-22
11
+
12
+ - Use modern ActiveRecord migration options for better Rails 7.x compatibility
13
+ - Ensure Github actions CI can run and uses Postgres appropriately
14
+ - Add examples for more sophisticated use cases
15
+ - Implement `#prune!` on storage backends
16
+ - Reformat all code using [standard](https://github.com/standardrb/standard) instead of wetransfer_style as it is both more relaxed and more modern
17
+
18
+ ## [1.0.0] - 2023-10-27
19
+
20
+ - Release 1.0 as the API can be considered stable and the gem has been in production for years
21
+
1
22
  ## [0.2.0] - 2022-04-08
2
23
 
3
24
  - Allow setting the global default TTL for the cached responses
data/README.md CHANGED
@@ -42,7 +42,7 @@ If you run only one Puma on one server (so multiple threads but one process) the
42
42
  * It uses a `Set` with a `Mutex` around it to store requests in progress
43
43
  * It uses a sorted array for expiration and cached responses.
44
44
 
45
- Needless to say, if your server terminates or restarts all the data goes dead with it. However
45
+ Needless to say, if your server terminates or restarts all the data disappears with it. This backend will also only work if you are running one Puma process (or other single-process server, and just one instance of it).
46
46
 
47
47
  ## Using your database for idempotency keys (via ActiveRecord)
48
48
 
@@ -71,9 +71,11 @@ config.middleware.insert Idempo, backend: Idempo::ActiveRecordBackend.new
71
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
72
 
73
73
  ```ruby
74
- Idempo::ActiveRecordBackend.new.model.where('expire_at_ < ?', Time.now).delete_all
74
+ Idempo::ActiveRecordBackend.new.prune!
75
75
  ```
76
76
 
77
+ If you need to use Idempo with PGBouncer you will need to write your own locking implementation based on fencing tokens or similar.
78
+
77
79
  ## Using Redis for idempotency keys
78
80
 
79
81
  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`:
@@ -88,7 +90,7 @@ If you have a configured Redis connection pool (and you should) - pass it to the
88
90
  config.middleware.insert Idempo, backend: Idempo::RedisBackend.new(config.redis_connection_pool)
89
91
  ```
90
92
 
91
- All data stored in Redis will have TTLs and will expire automatically.
93
+ All data stored in Redis will have TTLs and will expire automatically. Redis scripts ensure that updates to the stored idempotent responses and locking happen atomically.
92
94
 
93
95
 
94
96
  ## Installation
@@ -107,6 +109,10 @@ Or install it yourself as:
107
109
 
108
110
  $ gem install idempo
109
111
 
112
+ ## More advanced use cases
113
+
114
+ Check out the files in the `examples/` directory to see a few customisations you can do.
115
+
110
116
  ## Development
111
117
 
112
118
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/Rakefile CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
+ require "standard/rake"
5
6
 
6
7
  RSpec::Core::RakeTask.new(:spec)
7
8
 
@@ -0,0 +1,22 @@
1
+ # Sometimes you might need a different locking strategy than advisory locks,
2
+ # but still use the database-backed storage for idempotent responses. This can arise
3
+ # if you are using pgbouncer for instance, where advisory locks are not available
4
+ # when using the "transaction mode". You can modify the backend to use a different
5
+ # locking mechanism, but keep the rest.
6
+
7
+ class ActiveRecordBackendWithDistributedLock < Idempo::ActiveRecordBackend
8
+ class LocksViaService
9
+ def acquire(_conn, based_on_str)
10
+ LockingService.acquire("idempo-lk-#{based_on_str}")
11
+ end
12
+
13
+ def release(_conn, based_on_str)
14
+ LockingService.release("idempo-lk-#{based_on_str}")
15
+ true
16
+ end
17
+ end
18
+
19
+ def lock_implementation_for_connection(_connection)
20
+ LocksViaService.new
21
+ end
22
+ end
@@ -0,0 +1,41 @@
1
+ # Sometimes authentication is done using a Bearer token with a signature, or using another token format
2
+ # which includes some form of expiration. This means that every time a request is made, the `Authorization`
3
+ # HTTP header may have a different value, and thus the request fingerprint could change every time,
4
+ # even though the idempotency key is the same.
5
+ # For this case, a custom fingerprinting function can be used. For example, if the bearer token is
6
+ # generated in JWT format by the client, it may include the `iss` (issuer) claim, identifying the
7
+ # specific device. This identifier can then be used instead of the entire Authorization header.
8
+
9
+ module FingerprinterWithIssuerClaim
10
+ def self.call(idempotency_key, rack_request)
11
+ d = Digest::SHA256.new
12
+ d << idempotency_key << "\n"
13
+ d << rack_request.url << "\n"
14
+ d << rack_request.request_method << "\n"
15
+ d << extract_jwt_iss_claim(rack_request) << "\n"
16
+ while (chunk = rack_request.env["rack.input"].read(1024 * 65))
17
+ d << chunk
18
+ end
19
+ Base64.strict_encode64(d.digest)
20
+ ensure
21
+ rack_request.env["rack.input"].rewind
22
+ end
23
+
24
+ def self.extract_jwt_iss_claim(rack_request)
25
+ header_value = rack_request.get_header("HTTP_AUTHORIZATION").to_s
26
+ return header_value unless header_value.start_with?("Bearer ")
27
+
28
+ jwt = header_value.delete_prefix("Bearer ")
29
+ # This is decoding without verification, but in this case it is reasonably safe
30
+ # as we are not actually authenticating the request - just using the `iss` claim.
31
+ # It can make the app slightly more sensitive to replay attacks but since the request
32
+ # is idempotent, an already executed (and authenticated) request that generated a
33
+ # cached response is reasonably safe to serve out.
34
+ unverified_claims, _unverified_header = JWT.decode(jwt, _key = nil, _verify = false)
35
+ unverified_claims.fetch("iss")
36
+ rescue
37
+ # If we fail to pick up the claim or anything else - assume the request is non-idempotent
38
+ # as treating it otherwise may create a replay attack
39
+ SecureRandom.bytes(32)
40
+ end
41
+ end
data/idempo.gemspec CHANGED
@@ -3,21 +3,21 @@
3
3
  require_relative "lib/idempo/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "idempo"
7
- spec.version = Idempo::VERSION
8
- spec.authors = ["Julik Tarkhanov", "Pablo Crivella"]
9
- spec.email = ["me@julik.nl", "pablocrivella@gmail.com"]
10
-
11
- spec.summary = "Idempotency keys for all."
12
- spec.description = "Provides idempotency keys for Rack applications."
13
- spec.homepage = "https://github.com/julik/idempo"
14
- spec.license = "MIT"
6
+ spec.name = "idempo"
7
+ spec.version = Idempo::VERSION
8
+ spec.authors = ["Julik Tarkhanov", "Pablo Crivella"]
9
+ spec.email = ["me@julik.nl", "pablocrivella@gmail.com"]
10
+
11
+ spec.summary = "Idempotency keys for all."
12
+ spec.description = "Provides idempotency keys for Rack applications."
13
+ spec.homepage = "https://github.com/julik/idempo"
14
+ spec.license = "MIT"
15
15
  spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
16
16
 
17
17
  # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
18
  # to allow pushing to a single host or delete this section to allow pushing to any host.
19
19
  if spec.respond_to?(:metadata)
20
- spec.metadata['allowed_push_host'] = "https://rubygems.org"
20
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
21
21
  else
22
22
  raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
23
23
  end
@@ -31,14 +31,14 @@ Gem::Specification.new do |spec|
31
31
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
32
32
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
33
33
  end
34
- spec.bindir = "exe"
35
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
36
  spec.require_paths = ["lib"]
37
37
 
38
38
  # Uncomment to register a new dependency of your gem
39
39
  spec.add_dependency "rack"
40
40
  spec.add_dependency "msgpack"
41
- spec.add_dependency "measurometer", '~> 1.3'
41
+ spec.add_dependency "measurometer", "~> 1.3"
42
42
 
43
43
  spec.add_development_dependency "rake", "~> 13.0"
44
44
  spec.add_development_dependency "rspec", "~> 3.0"
@@ -47,7 +47,7 @@ Gem::Specification.new do |spec|
47
47
  spec.add_development_dependency "activerecord"
48
48
  spec.add_development_dependency "mysql2"
49
49
  spec.add_development_dependency "pg"
50
- spec.add_development_dependency "wetransfer_style"
50
+ spec.add_development_dependency "standard"
51
51
 
52
52
  # For more information and examples about making a new gem, checkout our
53
53
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -1,17 +1,17 @@
1
1
  # This backend currently only works with mysql2 since it uses advisory locks
2
2
  class Idempo::ActiveRecordBackend
3
3
  def self.create_table(via_migration)
4
- via_migration.create_table 'idempo_responses', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci' do |t|
5
- t.string :idempotent_request_key, index: true, unique: true, null: false
4
+ via_migration.create_table "idempo_responses", charset: "utf8mb4", collation: "utf8mb4_unicode_ci" do |t|
5
+ t.string :idempotent_request_key, index: {unique: true}, null: false
6
6
  t.datetime :expire_at, index: true, null: false # Needs an index for cleanup
7
- t.binary :idempotent_response_payload, size: :medium
7
+ t.binary :idempotent_response_payload, limit: Idempo::SAVED_RESPONSE_BODY_SIZE_LIMIT
8
8
  t.timestamps
9
9
  end
10
10
  end
11
11
 
12
12
  class Store < Struct.new(:key, :model)
13
13
  def lookup
14
- model.where(idempotent_request_key: key).where('expire_at > ?', Time.now).first&.idempotent_response_payload
14
+ model.where(idempotent_request_key: key).where("expire_at > ?", Time.now).first&.idempotent_response_payload
15
15
  end
16
16
 
17
17
  def store(data:, ttl:)
@@ -27,18 +27,18 @@ class Idempo::ActiveRecordBackend
27
27
 
28
28
  class PostgresLock
29
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)
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
32
  end
33
33
 
34
34
  def release(conn, based_on_str)
35
- conn.select_value('SELECT pg_advisory_unlock(%d)' % derive_lock_key(based_on_str))
35
+ conn.select_value("SELECT pg_advisory_unlock(%d)" % derive_lock_key(based_on_str))
36
36
  end
37
37
 
38
38
  def derive_lock_key(from_str)
39
39
  # The key must be a single bigint (signed long)
40
40
  hash_bytes = Digest::SHA1.digest(from_str)
41
- hash_bytes[0...8].unpack('l_').first
41
+ hash_bytes[0...8].unpack1("l_")
42
42
  end
43
43
  end
44
44
 
@@ -59,35 +59,51 @@ class Idempo::ActiveRecordBackend
59
59
  end
60
60
 
61
61
  def initialize
62
- require 'active_record'
62
+ require "active_record"
63
+ @memory_lock = Idempo::MemoryLock.new
63
64
  end
64
65
 
65
66
  # Allows the model to be defined lazily without having to require active_record when this module gets loaded
66
67
  def model
67
68
  @model_class ||= Class.new(ActiveRecord::Base) do
68
- self.table_name = 'idempo_responses'
69
+ self.table_name = "idempo_responses"
69
70
  end
70
71
  end
71
72
 
72
73
  def with_idempotency_key(request_key)
73
- db_safe_key = Digest::SHA1.base64digest(request_key)
74
- lock = lock_implementation_for_connection(model.connection)
74
+ # We need to use an in-memory lock because database advisory locks are
75
+ # reentrant. Both Postgres and MySQL allow multiple acquisitions of the
76
+ # same advisory lock within the same connection - in most Rails/Rack apps
77
+ # this translates to "within the same thread". This means that if one
78
+ # elects to use a non-threading webserver (like Falcon), or tests Idempo
79
+ # within the same thread (like we do), they won't get advisory locking
80
+ # for concurrent requests. Therefore a staged lock is required. First we apply
81
+ # the memory lock (for same thread on this process/multiple threads on this
82
+ # process) and then once we have that - the DB lock.
83
+ @memory_lock.with(request_key) do
84
+ db_safe_key = Digest::SHA1.base64digest(request_key)
85
+ database_lock = lock_implementation_for_connection(model.connection)
86
+ raise Idempo::ConcurrentRequest unless database_lock.acquire(model.connection, request_key)
75
87
 
76
- raise Idempo::ConcurrentRequest unless lock.acquire(model.connection, request_key)
77
-
78
- begin
79
- yield(Store.new(db_safe_key, model))
80
- ensure
81
- lock.release(model.connection, request_key)
88
+ begin
89
+ yield(Store.new(db_safe_key, model))
90
+ ensure
91
+ database_lock.release(model.connection, request_key)
92
+ end
82
93
  end
83
94
  end
84
95
 
96
+ # Deletes expired cached Idempo responses from the database, in batches
97
+ def prune!
98
+ model.where("expire_at < ?", Time.now).in_batches.delete_all
99
+ end
100
+
85
101
  private
86
102
 
87
103
  def lock_implementation_for_connection(connection)
88
- if connection.adapter_name =~ /^mysql2/i
104
+ if /^mysql2/i.match?(connection.adapter_name)
89
105
  MysqlLock.new
90
- elsif connection.adapter_name =~ /^postgres/i
106
+ elsif /^postgres/i.match?(connection.adapter_name)
91
107
  PostgresLock.new
92
108
  else
93
109
  raise "Unsupported database driver #{model.connection.adapter_name.downcase} - we don't know whether it supports advisory locks"
@@ -1,5 +1,3 @@
1
- require 'json'
2
-
3
1
  class Idempo::ConcurrentRequestErrorApp
4
2
  RETRY_AFTER_SECONDS = 2.to_s
5
3
 
@@ -10,6 +8,6 @@ class Idempo::ConcurrentRequestErrorApp
10
8
  message: "Another request with this idempotency key is still in progress, please try again later"
11
9
  }
12
10
  }
13
- [429, {'Retry-After' => RETRY_AFTER_SECONDS, 'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
11
+ [429, {"Retry-After" => RETRY_AFTER_SECONDS, "Content-Type" => "application/json"}, [JSON.pretty_generate(res)]]
14
12
  end
15
13
  end
@@ -1,5 +1,3 @@
1
- require 'json'
2
-
3
1
  class Idempo::MalformedKeyErrorApp
4
2
  def self.call(env)
5
3
  res = {
@@ -8,6 +6,6 @@ class Idempo::MalformedKeyErrorApp
8
6
  message: "The Idempotency-Key header provided was empty or malformed"
9
7
  }
10
8
  }
11
- [400, {'Content-Type' => 'application/json'}, [JSON.pretty_generate(res)]]
9
+ [400, {"Content-Type" => "application/json"}, [JSON.pretty_generate(res)]]
12
10
  end
13
11
  end
@@ -1,12 +1,9 @@
1
1
  class Idempo::MemoryBackend
2
2
  def initialize
3
- require 'set'
4
- require_relative 'response_store'
5
-
6
- @requests_in_flight_mutex = Mutex.new
7
- @in_progress = Set.new
8
- @store_mutex = Mutex.new
3
+ require_relative "response_store"
4
+ @lock = Idempo::MemoryLock.new
9
5
  @response_store = Idempo::ResponseStore.new
6
+ @store_mutex = Mutex.new
10
7
  end
11
8
 
12
9
  class Store < Struct.new(:store_mutex, :response_store, :key, keyword_init: true)
@@ -24,22 +21,13 @@ class Idempo::MemoryBackend
24
21
  end
25
22
 
26
23
  def with_idempotency_key(request_key)
27
- did_insert = @requests_in_flight_mutex.synchronize do
28
- if @in_progress.include?(request_key)
29
- false
30
- else
31
- @in_progress << request_key
32
- true
33
- end
34
- end
35
-
36
- raise Idempo::ConcurrentRequest unless did_insert
37
-
38
- store = Store.new(store_mutex: @store_mutex, response_store: @response_store, key: request_key)
39
- begin
24
+ @lock.with(request_key) do
25
+ store = Store.new(store_mutex: @store_mutex, response_store: @response_store, key: request_key)
40
26
  yield(store)
41
- ensure
42
- @requests_in_flight_mutex.synchronize { @in_progress.delete(request_key) }
43
27
  end
44
28
  end
29
+
30
+ def prune!
31
+ @response_store.prune
32
+ end
45
33
  end
@@ -0,0 +1,21 @@
1
+ # A memory lock prevents multiple requests with the same request
2
+ # fingerprint from running concurrently
3
+ class Idempo::MemoryLock
4
+ def initialize
5
+ @requests_in_flight_mutex = Mutex.new
6
+ @in_progress = Set.new
7
+ end
8
+
9
+ def with(request_key)
10
+ @requests_in_flight_mutex.synchronize do
11
+ if @in_progress.include?(request_key)
12
+ raise Idempo::ConcurrentRequest
13
+ else
14
+ @in_progress << request_key
15
+ end
16
+ end
17
+ yield
18
+ ensure
19
+ @requests_in_flight_mutex.synchronize { @in_progress.delete(request_key) }
20
+ end
21
+ end
@@ -54,7 +54,7 @@ class Idempo::RedisBackend
54
54
  Idempo::RedisBackend.eval_or_evalsha(r, SET_WITH_TTL_IF_LOCK_STILL_HELD_SCRIPT, keys: keys, argv: argv)
55
55
  end
56
56
 
57
- Measurometer.increment_counter('idempo.redis_lock_state_when_saving_response', 1, state: outcome_of_save)
57
+ Measurometer.increment_counter("idempo.redis_lock_state_when_saving_response", 1, state: outcome_of_save)
58
58
  end
59
59
  end
60
60
 
@@ -65,8 +65,8 @@ class Idempo::RedisBackend
65
65
  end
66
66
 
67
67
  def initialize(redis_or_connection_pool = Redis.new)
68
- require 'redis'
69
- require 'securerandom'
68
+ require "redis"
69
+ require "securerandom"
70
70
  @redis_pool = redis_or_connection_pool.respond_to?(:with) ? redis_or_connection_pool : NullPool.new(redis_or_connection_pool)
71
71
  end
72
72
 
@@ -84,10 +84,14 @@ class Idempo::RedisBackend
84
84
  outcome_of_del = @redis_pool.with do |r|
85
85
  Idempo::RedisBackend.eval_or_evalsha(r, DELETE_BY_KEY_AND_VALUE_SCRIPT, keys: [lock_key], argv: [token])
86
86
  end
87
- Measurometer.increment_counter('idempo.redis_lock_state_when_releasing_lock', 1, state: outcome_of_del)
87
+ Measurometer.increment_counter("idempo.redis_lock_state_when_releasing_lock", 1, state: outcome_of_del)
88
88
  end
89
89
  end
90
90
 
91
+ def prune!
92
+ # Do nothing
93
+ end
94
+
91
95
  def self.eval_or_evalsha(redis, script_code, keys:, argv:)
92
96
  script_sha = Digest::SHA1.hexdigest(script_code)
93
97
  redis.evalsha(script_sha, keys: keys, argv: argv)
@@ -4,12 +4,12 @@ module Idempo::RequestFingerprint
4
4
  d << idempotency_key << "\n"
5
5
  d << rack_request.url << "\n"
6
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)
7
+ d << rack_request.get_header("HTTP_AUTHORIZATION").to_s << "\n"
8
+ while (chunk = rack_request.env["rack.input"].read(1024 * 65))
9
9
  d << chunk
10
10
  end
11
11
  Base64.strict_encode64(d.digest)
12
12
  ensure
13
- rack_request.env['rack.input'].rewind
13
+ rack_request.env["rack.input"].rewind
14
14
  end
15
15
  end
@@ -25,8 +25,6 @@ class Idempo::ResponseStore
25
25
  nil
26
26
  end
27
27
 
28
- private
29
-
30
28
  def prune
31
29
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
30
  items_to_delete = remove_lower_than(@expiries, now, &:expire_at)
@@ -35,6 +33,8 @@ class Idempo::ResponseStore
35
33
  end
36
34
  end
37
35
 
36
+ private
37
+
38
38
  def binary_insert(array, item, &property_getter)
39
39
  at_i = array.bsearch_index do |stored_item|
40
40
  yield(stored_item) <= yield(item)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempo
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.1"
5
5
  end
data/lib/idempo.rb CHANGED
@@ -1,21 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zlib'
4
- require 'msgpack'
5
- require 'base64'
6
- require 'digest'
7
- require 'json'
8
- require 'measurometer'
9
-
10
- require_relative "idempo/version"
11
- require_relative "idempo/request_fingerprint"
12
- require_relative "idempo/memory_backend"
13
- require_relative "idempo/redis_backend"
14
- require_relative "idempo/active_record_backend"
15
- require_relative "idempo/malformed_key_error_app"
16
- require_relative "idempo/concurrent_request_error_app"
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+ require "measurometer"
7
+ require "msgpack"
8
+ require "zlib"
9
+ require "set"
10
+
11
+ require "idempo/version"
17
12
 
18
13
  class Idempo
14
+ autoload :ConcurrentRequestErrorApp, "idempo/concurrent_request_error_app"
15
+ autoload :MalformedKeyErrorApp, "idempo/malformed_key_error_app"
16
+ autoload :MemoryBackend, "idempo/memory_backend"
17
+ autoload :RedisBackend, "idempo/redis_backend"
18
+ autoload :ActiveRecordBackend, "idempo/active_record_backend"
19
+ autoload :RequestFingerprint, "idempo/request_fingerprint"
20
+ autoload :MemoryLock, "idempo/memory_lock"
21
+
19
22
  DEFAULT_TTL_SECONDS = 30
20
23
  SAVED_RESPONSE_BODY_SIZE_LIMIT = 4 * 1024 * 1024
21
24
 
@@ -37,44 +40,44 @@ class Idempo
37
40
  def call(env)
38
41
  req = Rack::Request.new(env)
39
42
  return @app.call(env) if request_verb_idempotent?(req)
40
- return @app.call(env) unless idempotency_key_header = extract_idempotency_key_from(env)
43
+ return @app.call(env) unless (idempotency_key_header = extract_idempotency_key_from(env))
41
44
 
42
45
  # The RFC requires that the Idempotency-Key header value is enclosed in quotes
43
46
  idempotency_key_header_value = unquote(idempotency_key_header)
44
- raise MalformedIdempotencyKey if idempotency_key_header_value == ''
47
+ raise MalformedIdempotencyKey if idempotency_key_header_value == ""
45
48
 
46
49
  request_key = @fingerprint_calculator.call(idempotency_key_header_value, req)
47
50
 
48
51
  @backend.with_idempotency_key(request_key) do |store|
49
- if stored_response = store.lookup
50
- Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'store')
52
+ if (stored_response = store.lookup)
53
+ Measurometer.increment_counter("idempo.responses_served_from", 1, from: "store")
51
54
  return from_persisted_response(stored_response)
52
55
  end
53
56
 
54
57
  status, headers, body = @app.call(env)
55
58
 
56
- expires_in_seconds = (headers.delete('X-Idempo-Persist-For-Seconds') || @persist_for_seconds).to_i
59
+ expires_in_seconds = (headers.delete("X-Idempo-Persist-For-Seconds") || @persist_for_seconds).to_i
57
60
  if response_may_be_persisted?(status, headers, body)
58
61
  # Body is replaced with a cached version since a Rack response body is not rewindable
59
62
  marshaled_response, body = serialize_response(status, headers, body)
60
63
  store.store(data: marshaled_response, ttl: expires_in_seconds)
61
64
  end
62
65
 
63
- Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'freshly-generated')
66
+ Measurometer.increment_counter("idempo.responses_served_from", 1, from: "freshly-generated")
64
67
  [status, headers, body]
65
68
  end
66
69
  rescue MalformedIdempotencyKey
67
- Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'malformed-idempotency-key')
70
+ Measurometer.increment_counter("idempo.responses_served_from", 1, from: "malformed-idempotency-key")
68
71
  @malformed_key_error_app.call(env)
69
72
  rescue ConcurrentRequest
70
- Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'conflict-concurrent-request')
73
+ Measurometer.increment_counter("idempo.responses_served_from", 1, from: "conflict-concurrent-request")
71
74
  @concurrent_request_error_app.call(env)
72
75
  end
73
76
 
74
77
  private
75
78
 
76
79
  def from_persisted_response(marshaled_response)
77
- if marshaled_response[-2..-1] != ':1'
80
+ if marshaled_response[-2..] != ":1"
78
81
  raise Error, "Unknown serialization of the marshaled response"
79
82
  else
80
83
  MessagePack.unpack(Zlib.inflate(marshaled_response[0..-3]))
@@ -84,34 +87,35 @@ class Idempo
84
87
  def serialize_response(status, headers, rack_response_body)
85
88
  # Buffer the Rack response body, we can only do that once (it is non-rewindable)
86
89
  body_chunks = []
87
- rack_response_body.each { |chunk| body_chunks << chunk.dup }
88
- rack_response_body.close if rack_response_body.respond_to?(:close)
90
+ rack_response_body.each { |chunk| body_chunks << chunk.dup }
89
91
 
90
92
  # Only keep headers which are strings
91
93
  stringified_headers = headers.each_with_object({}) do |(header, value), filtered|
92
- filtered[header] = value if !header.start_with?('rack.') && value.is_a?(String)
94
+ filtered[header] = value if !header.start_with?("rack.") && value.is_a?(String)
93
95
  end
94
96
 
95
97
  message_packed_str = MessagePack.pack([status, stringified_headers, body_chunks])
96
98
  deflated_message_packed_str = Zlib.deflate(message_packed_str) + ":1"
97
- Measurometer.increment_counter('idempo.response_total_generated_bytes', deflated_message_packed_str.bytesize)
98
- Measurometer.add_distribution_value('idempo.response_size_bytes', deflated_message_packed_str.bytesize)
99
+ Measurometer.increment_counter("idempo.response_total_generated_bytes", deflated_message_packed_str.bytesize)
100
+ Measurometer.add_distribution_value("idempo.response_size_bytes", deflated_message_packed_str.bytesize)
99
101
 
100
102
  # Add the version specifier at the end, because slicing a string in Ruby at the end
101
103
  # (when we unserialize our response again) does a realloc, while slicing at the start
102
104
  # does not
103
105
  [deflated_message_packed_str, body_chunks]
106
+ ensure
107
+ rack_response_body.close if rack_response_body.respond_to?(:close)
104
108
  end
105
109
 
106
110
  def response_may_be_persisted?(status, headers, body)
107
- return false if headers.delete('X-Idempo-Policy') == 'no-store'
111
+ return false if headers.delete("X-Idempo-Policy") == "no-store"
108
112
  return false unless status_may_be_persisted?(status)
109
113
  return false unless body_size_within_limit?(headers, body)
110
114
  true
111
115
  end
112
116
 
113
117
  def body_size_within_limit?(response_headers, body)
114
- return response_headers['Content-Length'].to_i <= SAVED_RESPONSE_BODY_SIZE_LIMIT if response_headers['Content-Length']
118
+ return response_headers["Content-Length"].to_i <= SAVED_RESPONSE_BODY_SIZE_LIMIT if response_headers["Content-Length"]
115
119
 
116
120
  return false unless body.is_a?(Array) # Arbitrary iterable of unknown size
117
121
 
@@ -132,7 +136,7 @@ class Idempo
132
136
  end
133
137
 
134
138
  def extract_idempotency_key_from(env)
135
- env['HTTP_IDEMPOTENCY_KEY'] || env['HTTP_X_IDEMPOTENCY_KEY']
139
+ env["HTTP_IDEMPOTENCY_KEY"] || env["HTTP_X_IDEMPOTENCY_KEY"]
136
140
  end
137
141
 
138
142
  def request_verb_idempotent?(request)
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: 1.0.0
4
+ version: 1.2.1
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: 2023-10-27 00:00:00.000000000 Z
12
+ date: 2024-02-27 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -152,7 +152,7 @@ dependencies:
152
152
  - !ruby/object:Gem::Version
153
153
  version: '0'
154
154
  - !ruby/object:Gem::Dependency
155
- name: wetransfer_style
155
+ name: standard
156
156
  requirement: !ruby/object:Gem::Requirement
157
157
  requirements:
158
158
  - - ">="
@@ -176,6 +176,7 @@ files:
176
176
  - ".github/workflows/ci.yml"
177
177
  - ".gitignore"
178
178
  - ".rubocop.yml"
179
+ - ".standard.yml"
179
180
  - CHANGELOG.md
180
181
  - Gemfile
181
182
  - LICENSE.txt
@@ -183,12 +184,15 @@ files:
183
184
  - Rakefile
184
185
  - bin/console
185
186
  - bin/setup
187
+ - examples/custom_locking.rb
188
+ - examples/jwt_iss_fingerprint.rb
186
189
  - idempo.gemspec
187
190
  - lib/idempo.rb
188
191
  - lib/idempo/active_record_backend.rb
189
192
  - lib/idempo/concurrent_request_error_app.rb
190
193
  - lib/idempo/malformed_key_error_app.rb
191
194
  - lib/idempo/memory_backend.rb
195
+ - lib/idempo/memory_lock.rb
192
196
  - lib/idempo/redis_backend.rb
193
197
  - lib/idempo/request_fingerprint.rb
194
198
  - lib/idempo/response_store.rb
@@ -216,7 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
220
  - !ruby/object:Gem::Version
217
221
  version: '0'
218
222
  requirements: []
219
- rubygems_version: 3.0.3
223
+ rubygems_version: 3.1.6
220
224
  signing_key:
221
225
  specification_version: 4
222
226
  summary: Idempotency keys for all.