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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +193 -0
- data/examples/README.md +156 -0
- data/examples/config.ru +249 -0
- data/examples/end_to_end_test.rb +315 -0
- data/lib/dedupe_requests/configuration.rb +80 -0
- data/lib/dedupe_requests/controller.rb +145 -0
- data/lib/dedupe_requests/fingerprint.rb +80 -0
- data/lib/dedupe_requests/guard.rb +37 -0
- data/lib/dedupe_requests/redis_store.rb +68 -0
- data/lib/dedupe_requests/version.rb +5 -0
- data/lib/dedupe_requests.rb +33 -0
- metadata +103 -0
|
@@ -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,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: []
|