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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cabb55d1b9a54a93f4b72f552fed6d9a1c38a8343a3750444196150ce21a3ff0
4
+ data.tar.gz: 6308e4ef27a02cb46bf495f1dd1fd00535e83118a0cbbe99a65b2cf91d19cf82
5
+ SHA512:
6
+ metadata.gz: 561cc06ce39366c6a2e816c6b2f9f4404a0cafd59673e08e69809a8283e8a3ce8adec51cf2225d974a223099525c21c72ac810b437ac809e3c2bd932faf6e209
7
+ data.tar.gz: f20afbdb2841ec7cf9c3ece8eb686fc0f6e3574d5dc805bf641c30692cbdbb2f1e333f6b514c7d97721a8b756ead454b366ee9a012bf7e99370f6b8cb7dcb569
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0.pre1 (2026-06-16)
4
+
5
+ Initial release. See the [README](README.md) for full usage and configuration.
6
+
7
+ ### Summary
8
+ - Server-side de-duplication of inbound mutating requests — **POST/PUT/PATCH only** (GET/DELETE are never deduped). No client-supplied idempotency key required: the server computes a fingerprint of each request (caller + verb + path + query + body).
9
+ - Controller macro `dedupe_requests` with `on:` (add) and `skip:` (remove), plus `skip_dedupe_requests`, over an inherited per-action map — declare a baseline in `ApplicationController` and refine it per subclass. Guarded actions are matched by action name.
10
+ - Per-action TTL by repeating the macro line; actions without one fall back to the global `ttl`.
11
+ - Per-caller scoping via `caller_id` — by default derived from the `Authorization` header (or a Rails session cookie), and fully overridable with your own callable.
12
+ - Pluggable `fingerprint` override to replace the default fingerprint computation entirely.
13
+ - Three operating modes for safe rollout: `:off`, `:observe` (detect-only / shadow), and `:enforce` (reject duplicates).
14
+ - Configurable 409 conflict response (`conflict_status`, `conflict_body`), with an `X-Dedupe-Request` header set on rejections.
15
+ - Reliability: atomic `SET NX EX` claim with a random token and a token-safe Lua check-and-del release; **fails open** (allows the request through) when Redis is unreachable.
16
+ - Retry-friendly claim lifecycle: keeps the fingerprint on a 2xx or 3xx response (including Post/Redirect/Get), and releases it on a 4xx/5xx response or a raised exception, so a genuinely failed request can be retried.
17
+ - Observability hooks: `on_duplicate_detected` and `on_duplicate_rejected`.
18
+ - Configurable digest (`:sha256` default, plus `:sha1` / `:sha512` / `:md5`, or a callable), key `namespace`, and `logger`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tilo Sloboda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # dedupe_requests
2
+
3
+ Automatic server-side de-duplication of inbound mutating Rails requests (POST / PUT / PATCH), with **no client-side idempotency key required**.
4
+
5
+ When a client re-sends the same mutating request — because of a retry, a network timeout, a double-click, or a buggy client — a non-idempotent endpoint often turns the duplicate into a 5xx (the resource is already created or modified).
6
+
7
+ One go-to solution for this used to be to require the client to provide a idempotency key together with the request, and then reject duplicate requests (requests that use a previous idemptotency key).
8
+
9
+ `dedupe_requests` simplifies this, removing the requirement for providing an idempotency key, and instead auto-computes a fingerprint of each mutating request (effectively auto-generating the idempotency key on-the-fly), claims it atomically in Redis, and short-circuits a duplicate seen within a configurable window with a clean **409 Conflict** instead of letting it blow up your app.
10
+
11
+ This is different from the usual idempotency-key gems: the **server** computes the fingerprint from the request itself, so
12
+ * existing clients need no changes
13
+ * clients no longer need to send an idempotency_key
14
+
15
+ ## How it works
16
+
17
+ 1. A mutating request (POST/PUT/PATCH) arrives for a guarded action.
18
+ 2. The server computes a fingerprint: `digest(caller_id + verb + path + query + body)`.
19
+ 3. It runs an atomic `SET key <token> NX EX <ttl>` in Redis.
20
+ - **Key already existed** → it's a duplicate. In `enforce` mode, respond `409`; in `observe` mode, just record it and let it through.
21
+ - **Key created** → first occurrence. Run the action normally.
22
+ 4. After the action: a **2xx, or a 3xx redirect** (the Post/Redirect/Get pattern is a successful create), keeps the fingerprint until the TTL expires — so a later duplicate is blocked; a **4xx/5xx or a raised exception releases** the fingerprint, so a genuine retry of a failed request is allowed.
23
+
24
+ GET and DELETE are never deduped. Time is not part of the fingerprint — the window is the Redis TTL.
25
+
26
+ ## Installation
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ gem "dedupe_requests"
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### 1. Global defaults — an initializer
36
+
37
+ ```ruby
38
+ # config/initializers/dedupe_requests.rb
39
+ DedupeRequests.configure do |c|
40
+ c.redis = Redis.new(url: ENV["REDIS_URL"])
41
+ c.mode = :enforce # :off | :observe | :enforce
42
+ c.ttl = 90 # the dedup window, in seconds
43
+ c.digest = :sha256 # :sha256 | :sha512 | :sha1 | :md5 | ->(bytes) { ... }
44
+ c.namespace = "myapp" # Redis key prefix
45
+ c.caller_id = ->(controller) { controller.current_user&.id } # per-caller scoping
46
+ c.logger = Rails.logger # where Redis/fail-open errors are logged
47
+ end
48
+ ```
49
+
50
+ The guarded verbs are fixed — **POST, PUT, PATCH**. They're not a config knob, and GET/DELETE are never deduped.
51
+
52
+ ### 2. Per-controller — the `dedupe_requests` macro
53
+
54
+ Include the concern once (usually in `ApplicationController`), then declare which actions are guarded:
55
+
56
+ ```ruby
57
+ class ApplicationController < ActionController::Base
58
+ include DedupeRequests::Controller
59
+ dedupe_requests on: %i[create update] # project-wide baseline
60
+ end
61
+ ```
62
+
63
+ Each `dedupe_requests` line **adds** the actions it names to the list of deduplicated actions — it does not replace anything (same as Rails' own `before_action only:`). A controller inherits its parent's guarded actions and can add more or drop some:
64
+
65
+ The list of deduplicated actions is matched by **action name**: once the baseline names `create`, every controller that inherits it deduplicates its own `create` action — not just `ApplicationController`'s. Opt a controller out with `skip:`.
66
+
67
+ | Option | Effect on this controller |
68
+ | ------- | ---------------------------------------------- |
69
+ | `on:` | guard these actions (uses this line's `ttl:`) |
70
+ | `skip:` | stop guarding these actions — no dedupe at all |
71
+
72
+ ```ruby
73
+ class OrdersController < ApplicationController
74
+ dedupe_requests on: %i[approve cancel] # adds approve/cancel to the inherited create/update
75
+ end
76
+
77
+ class DraftsController < ApplicationController
78
+ dedupe_requests skip: %i[create] # guards everything inherited except create
79
+ end
80
+ ```
81
+
82
+ #### Per-action TTL
83
+
84
+ A `ttl:` applies to exactly the actions named on its line. Give different actions different windows by repeating the line — a list shares one TTL:
85
+
86
+ ```ruby
87
+ class PaymentsController < ApplicationController
88
+ dedupe_requests on: %i[create charge], ttl: 120 # create + charge → 120s
89
+ dedupe_requests on: [:refund], ttl: 600 # refund → 600s
90
+ end
91
+ ```
92
+
93
+ An action with no `ttl:` falls back to the global `config.ttl`; re-declaring an action updates its TTL.
94
+
95
+ You never specify HTTP verbs per action — the route already determines the verb, and the gem only ever guards POST/PUT/PATCH.
96
+
97
+ ### 3. Per-caller identity (`caller_id`)
98
+
99
+ Dedup is scoped per caller, so two different users sending the same payload don't collide. `caller_id` is a callable given the **controller**, so it can read whatever identifies the caller:
100
+
101
+ ```ruby
102
+ DedupeRequests.configure do |c|
103
+ c.caller_id = ->(controller) { controller.current_user&.id } # current_user
104
+ # c.caller_id = ->(controller) { controller.request.get_header("HTTP_X_API_KEY") } # a header
105
+ # c.caller_id = ->(controller) { controller.some_method } # any controller method
106
+ end
107
+ ```
108
+
109
+ If you don't set it, the default derives identity from the `Authorization` header, falling back to a Rails session cookie — so token- and cookie-auth apps work with no configuration.
110
+
111
+ > **Note:** make sure you configure `caller_id` correctly for your API. If it can't derive an identity (no `Authorization` header and no session cookie), it falls back to `nil` — and then *different* callers sending the same payload to the same endpoint are treated as one request, so the second gets a 409. That's probably not what you want, so set `caller_id` to whatever identifies a caller in your app.
112
+
113
+ ## Modes and safe rollout
114
+
115
+ `mode` has three states:
116
+
117
+ - `:off` — disabled; no fingerprinting, no storage.
118
+ - `:observe` — **shadow mode**: compute and store fingerprints and fire the metrics hooks, but never return a 409. Duplicates are detected and reported only.
119
+ - `:enforce` — detect, store, and reject duplicates with a 409.
120
+
121
+ Recommended rollout on a live service: enable `:observe`, build a dashboard from the `duplicate_detected` hook, watch real volume for a week or two, then flip to `:enforce`.
122
+
123
+ ## Observability
124
+
125
+ Wire the hooks to your metrics/logging backend (Datadog, StatsD, logs — your choice):
126
+
127
+ ```ruby
128
+ DedupeRequests.configure do |c|
129
+ c.on_duplicate_detected = ->(info) { StatsD.increment("dedupe.detected", tags: { controller: info[:controller], action: info[:action], verb: info[:verb] }) }
130
+ c.on_duplicate_rejected = ->(info) { StatsD.increment("dedupe.rejected", tags: { controller: info[:controller], action: info[:action], verb: info[:verb] }) }
131
+ end
132
+ ```
133
+
134
+ Each hook receives `{ fingerprint:, controller:, action:, verb:, path: }`. `duplicate_detected` fires in both `observe` and `enforce`; `duplicate_rejected` only when a 409 is actually returned.
135
+
136
+ When tagging metrics, use only `controller`, `action`, and `verb` — these come from a small fixed set. Do **not** tag with `fingerprint` or `path`: the fingerprint is unique per request and the path usually contains record ids, so tagging with them creates a separate counter per request (a surprise bill on Datadog, or dropped series and broken dashboards). Log those instead if you need them.
137
+
138
+ ## The 409 response
139
+
140
+ Default body (override via `config.conflict_body`, and status via `config.conflict_status`):
141
+
142
+ ```json
143
+ {
144
+ "errors": [{
145
+ "error_key": "base",
146
+ "category": "duplicate_operation",
147
+ "message": "Duplicate request detected. A matching request is in-flight or recently completed."
148
+ }]
149
+ }
150
+ ```
151
+
152
+ A `409` is deliberate: well-behaved retrying clients do **not** loop on a 409 (they do on 5xx), so a duplicate is rejected cleanly without triggering further retries.
153
+
154
+ ## Reliability
155
+
156
+ - **Fail open.** If Redis is unreachable, the request proceeds normally — a Redis outage never blocks traffic. Redis errors are rescued and logged (set `config.logger`). The logger is used **only** for these Redis/fail-open errors — not for normal duplicate handling (use the hooks above for that) — and it is wired automatically only when the store is built from `config.redis`. If you inject your own `config.store`, pass it a logger directly.
157
+ - **Token-safe release.** Each claim stores a random token; release deletes the key only if it still holds that token (via a Lua check-and-del), so a slow request whose TTL expired can't wipe a newer request's fresh claim.
158
+ - **Compile Ruby with OpenSSL — for speed.** The fingerprint hashes the request body on the hot path. It uses `OpenSSL::Digest`, which runs on the CPU's SHA instructions (SHA-NI / ARM crypto) at ~1.5–2 GB/s. If your Ruby is built **without** OpenSSL, the gem still works — it falls back to the stdlib `Digest` — but that's a portable software implementation (~300–500 MB/s, no SHA instructions), several times slower on large bodies. So build Ruby with OpenSSL in production.
159
+
160
+ ## Configuration reference
161
+
162
+ | Option | Default | Purpose |
163
+ | ------------------------ | -------------------- | ----------------------------------------------------------------- |
164
+ | `redis` | `nil` | A Redis client or a connection pool. |
165
+ | `store` | built from `redis` | Inject a custom store responding to `claim` / `release`. |
166
+ | `mode` | `:enforce` | `:off` / `:observe` / `:enforce`. |
167
+ | `ttl` | `90` | Dedup window, in seconds. |
168
+ | `digest` | `:sha256` | `:sha256` / `:sha512` / `:sha1` / `:md5`, or a callable. |
169
+ | `namespace` | `"dedupe_requests"` | Redis key prefix (`<namespace>:dedup:<hash>`). |
170
+ | `caller_id` | Authorization / session cookie | Callable **given the controller**, returns a per-caller identity (e.g. `->(c){ c.current_user&.id }`, a header via `c.request`, or any controller method). Default derives it from the Authorization header / session cookie. |
171
+ | `fingerprint` | `nil` | Callable **given the request**, returns the fingerprint string — fully overriding the default computation. |
172
+ | `conflict_status` | `409` | Status returned for a rejected duplicate. |
173
+ | `conflict_body` | structured errors | JSON body for a rejected duplicate. |
174
+ | `logger` | `nil` | Where Redis errors are logged. |
175
+ | `on_duplicate_detected` | `nil` | Hook fired when a duplicate is seen. |
176
+ | `on_duplicate_rejected` | `nil` | Hook fired when a duplicate is rejected with a 409. |
177
+
178
+ > **Why `caller_id` is given the controller but `fingerprint` is given the request:** they answer different questions at different layers. `caller_id` identifies *who* is calling — an app-level question that often needs controller context like `current_user`, so it receives the controller. `fingerprint` characterizes *which request* this is — a pure function of the HTTP request (verb + path + query + body), computed in the framework-agnostic core where the body is hashed on the hot path, so it receives the request directly. Each callable is handed the object that matches its job.
179
+
180
+ ## Limitations
181
+
182
+ Auto-hashing the payload means two *genuinely separate* requests with identical content (e.g. deliberately creating two identical records in quick succession) look like a duplicate, and the second gets a 409. Mitigations: keep the TTL short, and opt specific actions out with `skip_dedupe_requests` (or `skip:`). This is best-effort de-duplication, not exactly-once semantics. It does **not** use client-supplied idempotency keys at all — an `Idempotency-Key` (or any similar) header is ignored and has no effect on the fingerprint; de-duplication is entirely server-computed.
183
+
184
+ ## Development
185
+
186
+ ```sh
187
+ bundle install
188
+ bundle exec rspec
189
+ ```
190
+
191
+ ## License
192
+
193
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,156 @@
1
+ # dedupe_requests — runnable example & end-to-end test
2
+
3
+ This directory holds a small but real Rails API service that uses `dedupe_requests`, plus an automated test that drives it over real HTTP. It exists to show the gem working in a realistic setup and to prove, end to end, both the inheritance behavior (baseline at `ApplicationController`, then refine per subclass) and the request-lifecycle behavior (per-caller scoping, concurrency, claim release on failure, the verb filter, redirects, observe mode, and the configuration hooks — `on_duplicate_detected`, `on_duplicate_rejected`, and the `fingerprint` override).
4
+
5
+ There is no in-process stubbing here: the test boots a real Puma server on a real TCP socket, sends real HTTP requests with `Net::HTTP`, and asserts on the status codes that come back. The gem writes its claims to a real Redis; Redis expires them on its own TTL. The test never reads or writes Redis — it only speaks HTTP.
6
+
7
+ ## Files
8
+
9
+ | File | What it is |
10
+ | --------------------- | ------------------------------------------------------------------------------------------------ |
11
+ | `config.ru` | The Rails API service: one `ApplicationController` baseline, its subclasses, and the routes |
12
+ | `end_to_end_test.rb` | The automated test: boots `config.ru` under Puma, fires HTTP at it, checks every scenario |
13
+
14
+ ## Running it
15
+
16
+ You need a running Redis (the gem talks to it, not the test) and the bundle installed.
17
+
18
+ ```
19
+ redis-server & # if one isn't already running
20
+ bundle install
21
+ bundle exec rake integration # or: bundle exec ruby examples/end_to_end_test.rb
22
+ ```
23
+
24
+ The test prints a per-scenario report and exits `0` if every check passes, `1` otherwise.
25
+
26
+ By default it uses `redis://localhost:6379/15` and port `9377`. Override with the `REDIS_URL` and `PORT` environment variables. It does not flush or otherwise touch Redis — each run tags its requests with a unique per-run id (a `?run=<uuid>` query) so a rerun never collides with claims still alive from a previous run.
27
+
28
+ ### Poking at it by hand
29
+
30
+ You can also boot the service on its own and hit it with `curl`:
31
+
32
+ ```
33
+ redis-server &
34
+ bundle exec puma examples/config.ru -p 9292
35
+
36
+ curl -i -XPOST localhost:9292/widgets -H 'content-type: application/json' \
37
+ -H 'authorization: Bearer token-alice' -d '{"name":"Blue Widget","color":"blue","quantity":3}' # 201 Created
38
+ # the exact same request again, within the TTL:
39
+ curl -i -XPOST localhost:9292/widgets -H 'content-type: application/json' \
40
+ -H 'authorization: Bearer token-alice' -d '{"name":"Blue Widget","color":"blue","quantity":3}' # 409 Conflict (duplicate)
41
+ ```
42
+
43
+ ## The controller hierarchy
44
+
45
+ Everything inherits from one `ApplicationController` that declares the app-wide baseline. Each subclass then refines it, which is the whole point of the exercise:
46
+
47
+ | Controller | Declares | What it demonstrates |
48
+ | ---------------------- | --------------------------------- | -------------------------------------------------------- |
49
+ | `ApplicationController`| `dedupe_requests on: %i[create update]` | the app-wide baseline |
50
+ | `WidgetsController` | *(nothing)* | the baseline reaches a subclass that declares nothing |
51
+ | `OrdersController` | `dedupe_requests on: [:approve]` | adds an action on top of the inherited create/update |
52
+ | `DraftsController` | `dedupe_requests skip: [:create]` | drops one inherited action; the rest stay guarded |
53
+ | `PaymentsController` | `dedupe_requests on: [:create], ttl: 5` | overrides the TTL for one action |
54
+
55
+ A few more subclasses exist only to exercise the request-lifecycle behavior (they all inherit the baseline):
56
+
57
+ | Controller | What it is for |
58
+ | ---------------------- | ------------------------------------------------------------------------------------------------ |
59
+ | `SlowController` | a deliberately slow `create` (sleeps ~1s) so two requests can be genuinely in flight at once |
60
+ | `FailuresController` | a `create` that returns 422 and an `update` that raises — to show the claim is released on failure |
61
+ | `ReadController` | `index`/`destroy` guarded **by name** but reached via GET/DELETE, which are never deduplicated |
62
+ | `RedirectsController` | a `create` that returns a 303 redirect, to show a redirect keeps the claim |
63
+ | `HookedController` | a clean guarded `create` used only by the hooks scenario, so its recorded events are unambiguous |
64
+ | `DebugController` | test-only: exposes the recorded hook invocations at `GET /_hooks` so the test can read them |
65
+
66
+ Two things worth remembering about how this works (both are tested below):
67
+
68
+ - Deduplication only ever applies to **POST/PUT/PATCH**. GET and DELETE are never deduplicated.
69
+ - The guarded actions are matched **by action name**. Once the baseline names `create`, every controller that inherits it deduplicates its own `create` — not just `ApplicationController`'s.
70
+
71
+ ## What makes two requests a "duplicate"
72
+
73
+ The gem fingerprints **caller + verb + path + query string + body**. Two requests collide (the second is a duplicate) only when all of those match. In this example:
74
+
75
+ - The **caller** is taken from the `Authorization` header — the gem's default `caller_id`. The test sends a different `Authorization: Bearer <token>` per simulated caller (`alice`, `bob`, `carol`), so the same payload from two different callers is **not** a duplicate.
76
+ - The **body** is a realistic per-endpoint JSON payload. Each distinct logical request uses its own payload, so within a run only an intentional repeat looks like a duplicate.
77
+ - The **`?run=<uuid>` query** is only there to isolate one test run from the next; it is not meant to represent a real-world payload.
78
+
79
+ The service runs in `:enforce` mode by default, so a detected duplicate is rejected with **409 Conflict**. A first (or non-duplicate) request returns **201 Created**. Two later scenarios boot extra servers: one in `:observe` mode (where a duplicate is detected but allowed through), and one with a custom `fingerprint`.
80
+
81
+ The configuration hooks are wired up so the test can check they actually fire: `config.ru` records every `on_duplicate_detected`, `on_duplicate_rejected`, `fingerprint`-override, and `caller_id`-override invocation in memory and exposes them at `GET /_hooks`, which the test reads over HTTP.
82
+
83
+ ## The scenarios
84
+
85
+ The test runs scenarios (1)–(10) and (12) against one enforce-mode server, then boots a second server in observe mode for scenario (11), a third with a custom fingerprint for scenario (13), and a fourth with a custom caller_id for scenario (14).
86
+
87
+ ### (1) Baseline at the application-controller level
88
+
89
+ `WidgetsController` declares nothing of its own — it only inherits the baseline. The test confirms a repeated `POST /widgets` (and a repeated `PATCH /widgets/:id`) from the same caller is rejected with 409. This proves the baseline declared on `ApplicationController` guards a subclass that never mentions `dedupe_requests`.
90
+
91
+ ### (2) Skipping a baseline action in a subclass
92
+
93
+ `DraftsController` does `skip: [:create]`. The test sends the same `POST /drafts` twice and expects **201 both times** (create is no longer guarded here), while a repeated `PATCH /drafts/:id` is still rejected with 409 (update is still inherited). This proves `skip:` removes exactly one action and leaves the rest of the inherited set intact.
94
+
95
+ ### (3) Adding an action in a subclass
96
+
97
+ `OrdersController` does `on: [:approve]`. The test confirms a repeated `POST /orders/:id/approve` is rejected with 409 (the added action is now guarded), and that a repeated `POST /orders` is still rejected too (the inherited create/update baseline is untouched). This proves `on:` adds to the inherited set rather than replacing it.
98
+
99
+ ### (4) Changing the TTL in a subclass — proven by real expiry
100
+
101
+ `PaymentsController` overrides the TTL for `create` (5s) while everything else uses the global TTL (2s). The test proves the difference purely by behavior, with real waiting:
102
+
103
+ 1. Open a claim on `POST /orders` (2s) and on `POST /payments` (5s); duplicates of each are rejected with 409.
104
+ 2. Wait ~3s. Now `POST /orders` is allowed again (its 2s window expired) while `POST /payments` is still rejected (its 5s window is still open).
105
+ 3. Wait ~3s more. Now `POST /payments` is allowed again too (its 5s window finally expired).
106
+
107
+ This is the most "real life" check in the suite: nothing inspects Redis or the claim TTL directly — the test just lets real time pass and watches the shorter window reopen first.
108
+
109
+ ### (5) Per-caller scoping
110
+
111
+ `alice` posts a payment and her immediate repeat is rejected with 409. Then `bob` and `carol` post the **exact same payload** and both get 201 — a different caller is a different request, not a duplicate. `bob`'s own repeat is then rejected with 409. This proves deduplication is scoped per caller, so two different users sending identical content do not collide.
112
+
113
+ ### (6) Different payload, same caller
114
+
115
+ `alice` posts a red widget (201), then a green widget (201) — a different body is a different request, not a duplicate — then the red widget again (409). This is the complement of scenario (5): same caller, *different* payload is allowed; same caller, *same* payload is blocked.
116
+
117
+ ### (7) Concurrent in-flight duplicate
118
+
119
+ Two identical `POST /slow` requests are fired from the same caller at the same instant, in separate threads, against an action that sleeps ~1s. Exactly one wins (201) and the other is rejected (409). This is the scenario the gem exists for: the claim is taken *before* the action runs and held for its duration, so a second request that arrives while the first is still in flight is rejected — not just a duplicate that arrives after the first has finished.
120
+
121
+ ### (8) Release on failure
122
+
123
+ A failed request frees its claim so an identical retry is allowed instead of wrongly blocked. `POST /failures` returns 422 twice (not 409) — a 4xx/5xx response releases the claim. `PATCH /failures/:id` raises, returning 500 twice (not 409) — a raised exception releases the claim too. Only a successful (2xx/3xx) request keeps its claim.
124
+
125
+ ### (9) GET and DELETE are never deduplicated
126
+
127
+ `ReadController` guards `index` and `destroy` **by name**, but they are reached via GET and DELETE. Two identical `GET /reads` and two identical `DELETE /reads/:id` all return 200 — never 409. This proves the verb filter: only POST/PUT/PATCH are ever deduplicated, even when the action name is in the guarded set.
128
+
129
+ ### (10) A 3xx redirect keeps the claim
130
+
131
+ `POST /redirects` returns a 303 redirect (the Post/Redirect/Get pattern, which is a *successful* create). The first returns 303 and the duplicate is rejected with 409 — a redirect keeps the claim for the full TTL, just like a 2xx, so a duplicate is still blocked.
132
+
133
+ ### (11) Observe mode lets duplicates through
134
+
135
+ This runs against a second server booted with `DEDUPE_MODE=observe`. `alice` posts a payment (201) and the identical repeat also returns **201** — in observe mode a duplicate is detected but allowed through instead of being rejected with 409. The test then reads `GET /_hooks` and confirms `on_duplicate_detected` **did** fire while `on_duplicate_rejected` did **not** — that difference is the whole point of observe mode, and it's how you roll the gem out in shadow mode before enforcing.
136
+
137
+ ### (12) Duplicate-notification hooks fire
138
+
139
+ On the enforce server, `alice` posts to `/hooked` (201, claimed) and repeats it (409). The test then reads `GET /_hooks` and confirms `on_duplicate_detected` fired exactly once and `on_duplicate_rejected` fired exactly once for `/hooked`, and that the detected event carried the expected data (`action: "create"`, `verb: "POST"`, and a non-empty fingerprint). This proves both notification hooks run, with the right arguments, in enforce mode.
140
+
141
+ ### (13) The fingerprint override is used
142
+
143
+ This runs against a third server booted with a custom `fingerprint` callable that keys only on **verb + path** (ignoring caller and body). `alice` posts a widget (201); a *different* body (201 normally) now returns **409**, and so does a post from a *different caller* — because the custom fingerprint ignores both. The test also confirms via `GET /_hooks` that the override was invoked once per mutating request. This proves the `fingerprint` config replaces the default fingerprint logic entirely.
144
+
145
+ ### (14) The caller_id override is used
146
+
147
+ This runs against a fourth server booted with a custom `caller_id` callable that reads an **`X-Api-Key`** header (instead of the `Authorization` header the default uses). Two requests with the same `X-Api-Key` but *different* `Authorization` collide (the second is a **409**) — which only happens if the identity comes from `X-Api-Key` — while a request with a *different* `X-Api-Key` is treated as a new caller (201). The test also confirms via `GET /_hooks` that the override ran on each request and saw the expected key values. This proves a custom `caller_id` is what drives the per-caller scoping.
148
+
149
+ ## Notes
150
+
151
+ - The short TTLs (2s and 5s) exist so the expiry test in scenario (4) runs quickly; they are set via the `DEDUPE_TTL` and `DEDUPE_PAYMENT_TTL` environment variables that `config.ru` reads. The slow action's duration is set by `DEDUPE_SLOW_SECONDS` (default 1s).
152
+ - `DEDUPE_MODE` selects `enforce` (default) or `observe`; the test boots the observe-mode server on `PORT + 1`.
153
+ - `DEDUPE_CUSTOM_FINGERPRINT=1` swaps in the custom `fingerprint` callable; the test boots that server on `PORT + 2`.
154
+ - `DEDUPE_CUSTOM_CALLER_ID=1` swaps in the custom `caller_id` callable (reads `X-Api-Key`); the test boots that server on `PORT + 3`.
155
+ - `GET /_hooks` is a test-only endpoint that returns the hook invocations the server recorded; it is not part of the dedupe demo.
156
+ - The Puma output is written to a log in your temp directory (`dedupe_puma_<port>.log`) and is printed only if the server fails to start.
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # A real Rails API service that exercises every dedupe_requests feature, booted
4
+ # as a REAL HTTP server (Puma) for examples/end_to_end_test.rb.
5
+ #
6
+ # Run it standalone to poke at it by hand:
7
+ #
8
+ # redis-server & # needs a running Redis
9
+ # bundle exec puma examples/config.ru -p 9292 # boot the server
10
+ #
11
+ # curl -i -XPOST localhost:9292/widgets -d '{"a":1}' -H 'content-type: application/json' # 201
12
+ # curl -i -XPOST localhost:9292/widgets -d '{"a":1}' -H 'content-type: application/json' # 409 (duplicate)
13
+ #
14
+ # The controllers below form one inheritance tree off ApplicationController. A
15
+ # single boot covers the inheritance features and the request-lifecycle behavior:
16
+ #
17
+ # (1) baseline at the application-controller level -> WidgetsController (declares nothing)
18
+ # (2) skipping a baseline action in a subclass -> DraftsController (skip: [:create])
19
+ # (3) adding an action in a subclass -> OrdersController (on: [:approve])
20
+ # (4) changing the TTL in a subclass -> PaymentsController (on: [:create], ttl)
21
+ # - a slow action (for the concurrent in-flight test) -> SlowController
22
+ # - actions that fail / raise (claim is released) -> FailuresController
23
+ # - GET/DELETE actions guarded by name (never deduped) -> ReadController
24
+ # - a 3xx redirect (claim is kept) -> RedirectsController
25
+ #
26
+ # DEDUPE_MODE selects :enforce (default) or :observe so the test can boot a second
27
+ # server to check observe-mode pass-through.
28
+
29
+ require "logger" # ActiveSupport < 7.1 references ::Logger before requiring it
30
+ require "securerandom"
31
+ require "action_controller"
32
+ require "action_dispatch"
33
+ require "redis"
34
+ require_relative "../lib/dedupe_requests"
35
+
36
+ # Short TTLs on purpose: the integration test proves the TTL difference by waiting
37
+ # for the shorter window to expire while the longer one is still open.
38
+ GLOBAL_TTL = Integer(ENV.fetch("DEDUPE_TTL", "2"))
39
+ PAYMENT_TTL = Integer(ENV.fetch("DEDUPE_PAYMENT_TTL", "5"))
40
+
41
+ # Test instrumentation: every hook invocation is appended here (one Puma process,
42
+ # many threads, so a Mutex is enough), and exposed at GET /_hooks so the test can
43
+ # assert — over HTTP — that the right hooks fired with the right data.
44
+ HOOK_EVENTS = []
45
+ HOOK_MUTEX = Mutex.new
46
+ def record_hook(event)
47
+ HOOK_MUTEX.synchronize { HOOK_EVENTS << event }
48
+ end
49
+
50
+ DedupeRequests.configure do |c|
51
+ c.redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/15"))
52
+ c.mode = ENV.fetch("DEDUPE_MODE", "enforce").to_sym
53
+ c.ttl = GLOBAL_TTL
54
+ # caller_id is left at its default, which derives the caller identity from the
55
+ # request's Authorization header. The integration test sends a different
56
+ # `Authorization: Bearer <token>` per simulated caller, so the same payload from
57
+ # two different callers fingerprints differently and is NOT treated as a duplicate.
58
+
59
+ # Record the duplicate-notification hooks. on_duplicate_detected fires whenever a
60
+ # duplicate is seen (observe AND enforce); on_duplicate_rejected fires only when a
61
+ # duplicate is actually rejected (enforce mode).
62
+ c.on_duplicate_detected = ->(info) { record_hook(info.merge(hook: "detected")) }
63
+ c.on_duplicate_rejected = ->(info) { record_hook(info.merge(hook: "rejected")) }
64
+
65
+ # When asked, replace the whole fingerprint with a custom one that keys only on
66
+ # verb + path + query (ignoring caller AND body), so the test can prove the
67
+ # override took effect: two different bodies — or two different callers — now
68
+ # collide. It also records that the hook was invoked.
69
+ if ENV["DEDUPE_CUSTOM_FINGERPRINT"] == "1"
70
+ c.fingerprint = lambda do |request|
71
+ record_hook(hook: "fingerprint", path: request.path, verb: request.request_method)
72
+ "#{request.request_method}:#{request.path}?#{request.query_string}"
73
+ end
74
+ end
75
+
76
+ # When asked, replace caller_id with a custom one that identifies the caller by
77
+ # an X-Api-Key header (ignoring the Authorization header the default would use),
78
+ # so the test can prove this callable is what drives the per-caller scoping. It
79
+ # also records that the hook was invoked.
80
+ if ENV["DEDUPE_CUSTOM_CALLER_ID"] == "1"
81
+ c.caller_id = lambda do |controller|
82
+ key = controller.request.get_header("HTTP_X_API_KEY")
83
+ record_hook(hook: "caller_id", path: controller.request.path, key: key)
84
+ key
85
+ end
86
+ end
87
+ end
88
+
89
+ # Most actions just return 201 with a unique id, tagged with the resource name so
90
+ # a manual curl shows which controller answered.
91
+ module RenderOk
92
+ def render_ok(resource, extra = {})
93
+ render json: { ok: true, id: SecureRandom.uuid, resource: resource }.merge(extra), status: :created
94
+ end
95
+ end
96
+
97
+ # (1) Application-controller level: the baseline lives here. Every subclass
98
+ # inherits it, including ones that declare nothing of their own.
99
+ class ApplicationController < ActionController::API
100
+ include RenderOk
101
+ include DedupeRequests::Controller
102
+ dedupe_requests on: %i[create update]
103
+ end
104
+
105
+ # Declares NOTHING — proves the baseline reaches a bare subclass. The GET #index
106
+ # is reached below to show GET is never deduplicated.
107
+ class WidgetsController < ApplicationController
108
+ def create
109
+ render_ok("widget")
110
+ end
111
+
112
+ def update
113
+ render_ok("widget")
114
+ end
115
+
116
+ def index
117
+ render json: { widgets: [] }, status: :ok
118
+ end
119
+ end
120
+
121
+ # (3) Adds :approve on top of the inherited create/update.
122
+ class OrdersController < ApplicationController
123
+ dedupe_requests on: [:approve]
124
+
125
+ def create
126
+ render_ok("order")
127
+ end
128
+
129
+ def update
130
+ render_ok("order")
131
+ end
132
+
133
+ def approve
134
+ render_ok("order", approved: true)
135
+ end
136
+ end
137
+
138
+ # (2) Skips :create from the baseline; :update stays guarded.
139
+ class DraftsController < ApplicationController
140
+ dedupe_requests skip: [:create]
141
+
142
+ def create
143
+ render_ok("draft")
144
+ end
145
+
146
+ def update
147
+ render_ok("draft")
148
+ end
149
+ end
150
+
151
+ # (4) Overrides the TTL for :create only (PAYMENT_TTL instead of GLOBAL_TTL).
152
+ class PaymentsController < ApplicationController
153
+ dedupe_requests on: [:create], ttl: PAYMENT_TTL
154
+
155
+ def create
156
+ render_ok("payment")
157
+ end
158
+ end
159
+
160
+ # A deliberately slow action so the test can fire two requests that are genuinely
161
+ # in flight at the same time. :create is guarded by the inherited baseline.
162
+ class SlowController < ApplicationController
163
+ def create
164
+ sleep Float(ENV.fetch("DEDUPE_SLOW_SECONDS", "1"))
165
+ render_ok("slow")
166
+ end
167
+ end
168
+
169
+ # Failing actions. The claim is released on a 4xx/5xx response (#create) or a
170
+ # raised exception (#update), so an identical retry is NOT blocked.
171
+ class FailuresController < ApplicationController
172
+ def create
173
+ render json: { error: "unprocessable" }, status: :unprocessable_entity
174
+ end
175
+
176
+ def update
177
+ raise "simulated failure"
178
+ end
179
+ end
180
+
181
+ # Actions guarded BY NAME, but reached via GET/DELETE — which the gem never
182
+ # deduplicates. Repeats are allowed even though :index/:destroy are in the set.
183
+ class ReadController < ApplicationController
184
+ dedupe_requests on: %i[index destroy]
185
+
186
+ def index
187
+ render json: { items: [] }, status: :ok
188
+ end
189
+
190
+ def destroy
191
+ render json: { deleted: true }, status: :ok
192
+ end
193
+ end
194
+
195
+ # A 3xx redirect (Post/Redirect/Get) is treated as a successful create, so the
196
+ # claim is KEPT and a duplicate is still blocked. :create is baseline-guarded.
197
+ class RedirectsController < ApplicationController
198
+ def create
199
+ redirect_to "/widgets", status: :see_other
200
+ end
201
+ end
202
+
203
+ # A clean guarded endpoint used only by the hooks scenario, so its recorded
204
+ # detected/rejected events are unambiguous. :create is baseline-guarded.
205
+ class HookedController < ApplicationController
206
+ def create
207
+ render_ok("hooked")
208
+ end
209
+ end
210
+
211
+ # Test-only: exposes the recorded hook invocations so the test can read them over
212
+ # HTTP. Not part of the dedupe demo — it just reports what the hooks captured.
213
+ class DebugController < ActionController::API
214
+ def hooks
215
+ render json: { events: HOOK_EVENTS }, status: :ok
216
+ end
217
+ end
218
+
219
+ ROUTES = ActionDispatch::Routing::RouteSet.new
220
+ ROUTES.draw do
221
+ post "/widgets" => "widgets#create"
222
+ patch "/widgets/:id" => "widgets#update"
223
+ get "/widgets" => "widgets#index"
224
+
225
+ post "/orders" => "orders#create"
226
+ patch "/orders/:id" => "orders#update"
227
+ post "/orders/:id/approve" => "orders#approve"
228
+
229
+ post "/drafts" => "drafts#create"
230
+ patch "/drafts/:id" => "drafts#update"
231
+
232
+ post "/payments" => "payments#create"
233
+
234
+ post "/slow" => "slow#create"
235
+
236
+ post "/failures" => "failures#create"
237
+ patch "/failures/:id" => "failures#update"
238
+
239
+ get "/reads" => "read#index"
240
+ delete "/reads/:id" => "read#destroy"
241
+
242
+ post "/redirects" => "redirects#create"
243
+
244
+ post "/hooked" => "hooked#create"
245
+
246
+ get "/_hooks" => "debug#hooks"
247
+ end
248
+
249
+ run ROUTES