dedupe_requests 1.0.0.pre1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module DedupeRequests
6
+ # Redis-backed claim/release store.
7
+ #
8
+ # - claim: atomic SET key <token> NX EX <ttl>. Returns the token on success,
9
+ # false if the key already exists (duplicate), or :error if Redis is
10
+ # unreachable (fail open).
11
+ # - release: token-safe check-and-del via a Lua script — only deletes the key
12
+ # if it still holds OUR token, so a slow request whose TTL expired
13
+ # cannot wipe a newer request's fresh claim.
14
+ class RedisStore
15
+ RELEASE_SCRIPT = <<~LUA
16
+ if redis.call("get", KEYS[1]) == ARGV[1] then
17
+ return redis.call("del", KEYS[1])
18
+ else
19
+ return 0
20
+ end
21
+ LUA
22
+
23
+ # Wraps a bare Redis client so it responds to #with like a connection pool,
24
+ # giving one uniform access path regardless of what the user injected.
25
+ class NullPool
26
+ def initialize(redis)
27
+ @redis = redis
28
+ end
29
+
30
+ def with
31
+ yield @redis
32
+ end
33
+ end
34
+
35
+ def initialize(redis_or_pool, namespace: "dedupe_requests", logger: nil)
36
+ @pool = redis_or_pool.respond_to?(:with) ? redis_or_pool : NullPool.new(redis_or_pool)
37
+ @namespace = namespace
38
+ @logger = logger
39
+ end
40
+
41
+ def claim(fingerprint, ttl:)
42
+ token = SecureRandom.hex(16)
43
+ ok = @pool.with { |r| r.set(key(fingerprint), token, nx: true, ex: ttl) }
44
+ ok ? token : false
45
+ rescue StandardError => e
46
+ log(e)
47
+ :error
48
+ end
49
+
50
+ def release(fingerprint, token)
51
+ @pool.with { |r| r.eval(RELEASE_SCRIPT, keys: [key(fingerprint)], argv: [token]) }
52
+ true
53
+ rescue StandardError => e
54
+ log(e)
55
+ false
56
+ end
57
+
58
+ def key(fingerprint)
59
+ "#{@namespace}:dedup:#{fingerprint}"
60
+ end
61
+
62
+ private
63
+
64
+ def log(error)
65
+ @logger&.warn("[dedupe_requests] redis error: #{error.class}: #{error.message}")
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DedupeRequests
4
+ VERSION = "1.0.0.pre1"
5
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dedupe_requests/version"
4
+ require "dedupe_requests/fingerprint"
5
+ require "dedupe_requests/redis_store"
6
+ require "dedupe_requests/configuration"
7
+ require "dedupe_requests/guard"
8
+
9
+ module DedupeRequests
10
+ class Error < StandardError; end
11
+
12
+ # The only verbs ever guarded. GET and DELETE are deliberately never deduped.
13
+ MUTATING_VERBS = %w[POST PUT PATCH].freeze
14
+
15
+ class << self
16
+ def config
17
+ @config ||= Configuration.new
18
+ end
19
+ alias configuration config
20
+
21
+ def configure
22
+ yield config
23
+ config
24
+ end
25
+
26
+ def reset_configuration!
27
+ @config = Configuration.new
28
+ end
29
+ end
30
+ end
31
+
32
+ # The controller concern needs ActiveSupport (a runtime dependency).
33
+ require "dedupe_requests/controller"
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dedupe_requests
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.pre1
5
+ platform: ruby
6
+ authors:
7
+ - Tilo Sloboda
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '5.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ description: Detects and rejects duplicate inbound POST/PUT/PATCH requests with a
55
+ 409/conflict, with no client-side idempotency key required. The server auto-computes
56
+ a fingerprint of each mutating request, claims it atomically in Redis, and short-circuits
57
+ duplicates seen within a configurable time window, so they don't overwhelm your
58
+ server or cause 5xx errors.
59
+ email:
60
+ - tilo.sloboda@gmail.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - examples/README.md
69
+ - examples/config.ru
70
+ - examples/end_to_end_test.rb
71
+ - lib/dedupe_requests.rb
72
+ - lib/dedupe_requests/configuration.rb
73
+ - lib/dedupe_requests/controller.rb
74
+ - lib/dedupe_requests/fingerprint.rb
75
+ - lib/dedupe_requests/guard.rb
76
+ - lib/dedupe_requests/redis_store.rb
77
+ - lib/dedupe_requests/version.rb
78
+ homepage: https://github.com/tilo/dedupe_requests
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ homepage_uri: https://github.com/tilo/dedupe_requests
83
+ source_code_uri: https://github.com/tilo/dedupe_requests
84
+ rubygems_mfa_required: 'false'
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '2.6'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.6.9
100
+ specification_version: 4
101
+ summary: Automatic server-side de-duplication of inbound mutating Rails requests (POST/PUT/PATCH)
102
+ via a payload fingerprint and Redis.
103
+ test_files: []