idempo 0.1.1 → 0.2.0

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: c746ccbd1875f6a87ff6eabf4aa2566180fae441fa70eada8e7e703612ecd003
4
- data.tar.gz: fd2365ee176c8f2c5667f78721f7886a9511a9d45a8699ce3d5b7b83ecaff17a
3
+ metadata.gz: 96b24842e2d4d301bcf7a1470790a392fa1443a82ab2634e3aa81adbd173828c
4
+ data.tar.gz: 02243d85940b4e9f110fc6460eb925533d39ca62e331237b8e5ad4934f250703
5
5
  SHA512:
6
- metadata.gz: 1ede112b38e43dd06261438216a4afe4c5ef08348cb462a38ae2acf9a6b0f6cd530b74dbe469dd4889ef3364e1880803764bb80c65d94954715c19dd50eb67cd
7
- data.tar.gz: 69c3d9946da4cc27dbf2b5eeace3d577d62de3c15ca0750b66cbfa0854af7ec991e0da6cfd6d3a1a365337f7f6a0f364da3d9793ab94d13fd7792b026c2b68b7
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
- ## [Unreleased]
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,36 +5,108 @@ 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
8
+ ## Usage
9
9
 
10
- Add this line to your application's Gemfile:
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:
11
11
 
12
12
  ```ruby
13
- gem 'idempo'
13
+ use Idempo, backend: Idempo::RedisBackend.new(Rails.application.config.redis_connection_pool)
14
14
  ```
15
15
 
16
- And then execute:
16
+ and to initialize with a memory store as backend:
17
17
 
18
- $ bundle install
18
+ ```ruby
19
+ use Idempo
20
+ ```
19
21
 
20
- Or install it yourself as:
22
+ In principle, the following requests qualify to be cached used the idempotency key:
21
23
 
22
- $ gem install idempo
24
+ * Any request which is not a `GET`, `HEAD` or `OPTIONS` and...
25
+ * Provides an `Idempotency-Key` or `X-Idempotency-Key` header
23
26
 
24
- ## Usage
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.
25
28
 
26
- 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:
29
+ ## Controlling the behavior of Idempo from your application
30
+
31
+ You can control the behavior of Idempo using special response headers:
32
+
33
+ * Set `X-Idempo-Policy` to `no-store` to disable retention of the response even though it otherwise could be cached
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:
27
56
 
28
57
  ```ruby
29
- use Idempo, backend: Idempo::RedisBackend.new(Rails.application.config.redis_connection_pool)
58
+ class AddIdempoResponses < ActiveRecord::Migration[7.0]
59
+ def change
60
+ Idempo::ActiveRecordBackend.create_table(self)
61
+ end
62
+ end
30
63
  ```
31
64
 
32
- and to initialize with a memory store as backend:
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:
33
97
 
34
98
  ```ruby
35
- use Idempo, backend: Idempo::MemoryBackend.new)
99
+ gem 'idempo'
36
100
  ```
37
101
 
102
+ And then execute:
103
+
104
+ $ bundle install
105
+
106
+ Or install it yourself as:
107
+
108
+ $ gem install idempo
109
+
38
110
  ## Development
39
111
 
40
112
  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/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 = Base64.strict_encode64(request_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 did_acquire == 1
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.select_value("SELECT RELEASE_LOCK(%s)" % quoted_lock_name)
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
@@ -12,7 +12,7 @@ class Idempo::RedisBackend
12
12
  return "ok"
13
13
  else
14
14
  -- someone else holds the lock or it has expired
15
- return "lock_lost"
15
+ return "stale"
16
16
  end
17
17
  EOL
18
18
 
@@ -25,7 +25,7 @@ class Idempo::RedisBackend
25
25
  redis.call("set", KEYS[2], ARGV[2], "px", ARGV[3])
26
26
  return "ok"
27
27
  else
28
- return "lock_lost"
28
+ return "stale"
29
29
  end
30
30
  EOL
31
31
 
@@ -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_when_storing', 1, outcome: 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
 
@@ -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)
@@ -84,7 +84,7 @@ 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_release_lock', 1, outcome: 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
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempo
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
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
- DEFAULT_TTL = 30
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,47 +40,35 @@ 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
- idempotency_key_header = unquote(idempotency_key_header)
37
- raise MalformedIdempotencyKey if idempotency_key_header == ''
43
+ idempotency_key_header_value = unquote(idempotency_key_header)
44
+ raise MalformedIdempotencyKey if idempotency_key_header_value == ''
38
45
 
39
- fingerprint = compute_request_fingerprint(req)
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
44
- Measurometer.increment_counter('idempo.served', 1, via: 'cached')
50
+ Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'store')
45
51
  return from_persisted_response(stored_response)
46
52
  end
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)
55
61
  end
56
62
 
57
- Measurometer.increment_counter('idempo.served', 1, via: 'stored')
63
+ Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'freshly-generated')
58
64
  [status, headers, body]
59
65
  end
60
66
  rescue MalformedIdempotencyKey
61
- res = {
62
- ok: false,
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
- Measurometer.increment_counter('idempo.served', 1, via: 'concurrent-request-error')
70
- res = {
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)]]
70
+ Measurometer.increment_counter('idempo.responses_served_from', 1, from: 'conflict-concurrent-request')
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
- precomputed_body_size = body.inject(0) { |sum, chunk| sum + chunk.bytesize }
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,18 +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
- while chunk = req.env['rack.input'].read(1024 * 65)
145
- d << chunk
146
- end
147
- Base64.strict_encode64(d.digest)
148
- ensure
149
- req.env['rack.input'].rewind
150
- end
151
-
152
134
  def extract_idempotency_key_from(env)
153
135
  env['HTTP_IDEMPOTENCY_KEY'] || env['HTTP_X_IDEMPOTENCY_KEY']
154
136
  end
@@ -157,8 +139,12 @@ class Idempo
157
139
  request.get? || request.head? || request.options?
158
140
  end
159
141
 
142
+ def sum_of_string_bytesizes(in_array)
143
+ in_array.inject(0) { |sum, chunk| sum + chunk.bytesize }
144
+ end
145
+
160
146
  def unquote(str)
161
- # Do not use regular expressions so that we don't have to thing about a catastrophic lookahead
147
+ # Do not use regular expressions so that we don't have to think about a catastrophic lookahead
162
148
  double_quote = '"'
163
149
  if str.start_with?(double_quote) && str.end_with?(double_quote)
164
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.1.1
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: 2021-10-19 00:00:00.000000000 Z
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