diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2a59179..db72413 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -67,6 +67,12 @@ jobs: - name: Test run: bundle exec rake + # Lint once, on a modern Ruby only (rubocop drops old Rubies; running it on + # every cell would fail on 2.6/2.7). + - name: Lint + if: matrix.os == 'ubuntu' && matrix.ruby == '3.4' + run: bundle exec rubocop + # Upload coverage once (single representative cell) to avoid duplicate reports. - name: Upload coverage to Codecov if: matrix.os == 'ubuntu' && matrix.ruby == '3.4' diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4eaf3..8fc73e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,10 @@ ### Added - Initial release of `dedupe_requests`. - Server-side fingerprint de-duplication of POST/PUT/PATCH requests (no client idempotency key required). -- Controller macro `dedupe_requests` with `only:` / `also:` / `skip:` set operations over an inherited action set, plus `skip_dedupe_requests`. -- Global configuration via `DedupeRequests.configure` (redis, mode, ttl, digest, namespace, caller_id, fingerprint override, max_body_bytes, conflict status/body, logger, metrics hooks). +- Controller macro `dedupe_requests` with `only:` (add) and `skip:` (remove) over an inherited per-action map, plus `skip_dedupe_requests`; per-action TTL by repeating the line. +- Global configuration via `DedupeRequests.configure` (redis, mode, ttl, digest, namespace, caller_id, fingerprint override, conflict status/body, logger, metrics hooks). - Three operating modes: `:off`, `:observe` (shadow), `:enforce`. - Atomic `SET NX EX` claim with a random token and token-safe Lua check-and-del release. -- Releases the fingerprint on any non-2xx response or raised exception (around pattern), so failed requests can be retried. +- Keeps the fingerprint on a 2xx or 3xx (incl. Post/Redirect/Get) response; releases it on a 4xx/5xx response or a raised exception (around pattern), so failed requests can be retried. - Fail-open behavior when Redis is unreachable. - `duplicate_detected` / `duplicate_rejected` observability hooks. diff --git a/Gemfile b/Gemfile index 5e52586..a968ec9 100644 --- a/Gemfile +++ b/Gemfile @@ -5,9 +5,10 @@ source "https://rubygems.org" gemspec group :test do - gem "actionpack", ">= 7.0" - gem "rack-test" + gem "actionpack", ">= 5.2" gem "mock_redis" + gem "rack-test" gem "redis" + gem "rubocop", require: false gem "simplecov", require: false end diff --git a/README.md b/README.md index aed28e5..9025b6a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This is different from the usual idempotency-key gems: the **server** computes t 3. It runs an atomic `SET key NX EX ` in Redis. - **Key already existed** → it's a duplicate. In `enforce` mode, respond `409`; in `observe` mode, just record it and let it through. - **Key created** → first occurrence. Run the action normally. -4. After the action: a `2xx` keeps the fingerprint until the TTL expires (so a later duplicate is blocked); **any non-2xx or a raised exception releases the fingerprint**, so a genuine retry of a failed request is allowed. +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. GET and DELETE are never deduped. Time is not part of the fingerprint — the window is the Redis TTL. @@ -42,7 +42,7 @@ DedupeRequests.configure do |c| c.ttl = 90 # the dedup window, in seconds c.digest = :sha256 # :sha256 | :sha512 | :sha1 | :md5 | ->(bytes) { ... } c.namespace = "myapp" # Redis key prefix - c.caller_id = ->(req) { req.get_header("HTTP_AUTHORIZATION") } # per-caller scoping + c.caller_id = ->(controller) { controller.current_user&.id } # per-caller scoping c.logger = Rails.logger # where Redis/fail-open errors are logged end ``` @@ -56,36 +56,58 @@ Include the concern once (usually in `ApplicationController`), then declare whic ```ruby class ApplicationController < ActionController::Base include DedupeRequests::Controller - dedupe_requests only: %i[create update] # project-wide baseline + dedupe_requests on: %i[create update] # project-wide baseline end ``` -Subclasses adjust the inherited baseline with three set operations: +Each `dedupe_requests` line **adds** the actions it names to the guarded set — 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: -| Option | Meaning for this controller | Result vs. inherited set | -| ------- | ---------------------------------- | ------------------------ | -| `only:` | exact list — ignore the baseline | replace | -| `also:` | baseline **plus** these | inherited ∪ these | -| `skip:` | baseline **minus** these | inherited − these | +| Option | Effect on this controller | +| ------- | ---------------------------------------------- | +| `on:` | guard these actions (uses this line's `ttl:`) | +| `skip:` | stop guarding these actions — no dedupe at all | ```ruby -class ReportsController < ApplicationController - dedupe_requests only: %i[generate] # ignore baseline; just this action +class OrdersController < ApplicationController + dedupe_requests on: %i[approve cancel] # adds approve/cancel to the inherited create/update end class DraftsController < ApplicationController - dedupe_requests skip: %i[create] # baseline minus create + dedupe_requests skip: %i[create] # guards everything inherited except create end +``` -class OrdersController < ApplicationController - dedupe_requests also: %i[approve cancel] # baseline plus these two +#### Per-action TTL + +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: + +```ruby +class PaymentsController < ApplicationController + dedupe_requests on: %i[create charge], ttl: 120 # create + charge → 120s + dedupe_requests on: [:refund], ttl: 600 # refund → 600s end ``` -A per-controller TTL override rides on the same line: `dedupe_requests only: %i[create], ttl: 120`. +An action with no `ttl:` falls back to the global `config.ttl`; re-declaring an action updates its TTL. You never specify HTTP verbs per action — the route already determines the verb, and the gem only ever guards POST/PUT/PATCH. +### 3. Per-caller identity (`caller_id`) + +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: + +```ruby +DedupeRequests.configure do |c| + c.caller_id = ->(controller) { controller.current_user&.id } # current_user + # c.caller_id = ->(controller) { controller.request.get_header("HTTP_X_API_KEY") } # a header + # c.caller_id = ->(controller) { controller.some_method } # any controller method +end +``` + +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. + +> **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. + ## Modes and safe rollout `mode` has three states: @@ -102,13 +124,15 @@ Wire the hooks to your metrics/logging backend (Datadog, StatsD, logs — your c ```ruby DedupeRequests.configure do |c| - c.on_duplicate_detected = ->(info) { StatsD.increment("dedupe.detected", tags: info) } - c.on_duplicate_rejected = ->(info) { StatsD.increment("dedupe.rejected", tags: info) } + c.on_duplicate_detected = ->(info) { StatsD.increment("dedupe.detected", tags: { controller: info[:controller], action: info[:action], verb: info[:verb] }) } + c.on_duplicate_rejected = ->(info) { StatsD.increment("dedupe.rejected", tags: { controller: info[:controller], action: info[:action], verb: info[:verb] }) } end ``` 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. +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. + ## The 409 response Default body (override via `config.conflict_body`, and status via `config.conflict_status`): @@ -129,6 +153,7 @@ A `409` is deliberate: well-behaved retrying clients do **not** loop on a 409 (t - **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. - **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. +- **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. ## Configuration reference @@ -140,9 +165,8 @@ A `409` is deliberate: well-behaved retrying clients do **not** loop on a 409 (t | `ttl` | `90` | Dedup window, in seconds. | | `digest` | `:sha256` | `:sha256` / `:sha512` / `:sha1` / `:md5`, or a callable. | | `namespace` | `"dedupe_requests"` | Redis key prefix (`:dedup:`). | -| `caller_id` | Authorization / session cookie | Callable returning a per-caller identity. | +| `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. | | `fingerprint` | `nil` | Callable to fully override fingerprint computation. | -| `max_body_bytes` | `nil` | Cap how many body bytes are hashed (for very large payloads). | | `conflict_status` | `409` | Status returned for a rejected duplicate. | | `conflict_body` | structured errors | JSON body for a rejected duplicate. | | `logger` | `nil` | Where Redis errors are logged. | diff --git a/dedupe_requests.gemspec b/dedupe_requests.gemspec index 8a4e2ca..c4b0485 100644 --- a/dedupe_requests.gemspec +++ b/dedupe_requests.gemspec @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative "lib/dedupe_requests/version" - +# rubocop:disable Layout/LineLength Gem::Specification.new do |spec| spec.name = "dedupe_requests" spec.version = DedupeRequests::VERSION @@ -21,8 +21,9 @@ Gem::Specification.new do |spec| spec.files = Dir["lib/**/*.rb", "README.md", "CHANGELOG.md", "LICENSE.txt"] spec.require_paths = ["lib"] - spec.add_dependency "activesupport", ">= 7.0" + spec.add_dependency "activesupport", ">= 5.2" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.0" end +# rubocop:enable Layout/LineLength diff --git a/lib/dedupe_requests/configuration.rb b/lib/dedupe_requests/configuration.rb index 2df4ffa..6992cfa 100644 --- a/lib/dedupe_requests/configuration.rb +++ b/lib/dedupe_requests/configuration.rb @@ -12,9 +12,18 @@ class Configuration }] }.freeze - # Default per-caller identity: the Authorization header, falling back to a - # Rails-style session cookie. Override via `config.caller_id`. - DEFAULT_CALLER_ID = lambda do |request| + # Per-caller identity. The callable is given the CONTROLLER, so it can read + # anything the controller exposes — `current_user`, a helper method, or a + # header via `controller.request`. Examples: + # c.caller_id = ->(controller) { controller.current_user&.id } + # c.caller_id = ->(controller) { controller.request.get_header("HTTP_X_API_KEY") } + # + # The default derives identity from the request's Authorization header, + # falling back to a Rails-style session cookie (so token- and cookie-auth + # apps work with no configuration). It accepts either a controller or a bare + # request. + DEFAULT_CALLER_ID = lambda do |context| + request = context.respond_to?(:request) ? context.request : context if request.respond_to?(:get_header) auth = request.get_header("HTTP_AUTHORIZATION") return auth if auth && !auth.to_s.empty? @@ -26,7 +35,7 @@ class Configuration end attr_accessor :redis, :ttl, :digest, :namespace, :caller_id, :fingerprint, - :max_body_bytes, :conflict_status, :logger, + :conflict_status, :logger, :on_duplicate_detected, :on_duplicate_rejected attr_writer :store, :conflict_body attr_reader :mode @@ -40,7 +49,6 @@ def initialize @namespace = "dedupe_requests" @caller_id = DEFAULT_CALLER_ID @fingerprint = nil - @max_body_bytes = nil @conflict_status = 409 @logger = nil @on_duplicate_detected = nil @@ -53,6 +61,7 @@ def mode=(value) unless MODES.include?(sym) raise ArgumentError, "unknown mode #{value.inspect} (expected one of #{MODES.join(', ')})" end + @mode = sym end diff --git a/lib/dedupe_requests/controller.rb b/lib/dedupe_requests/controller.rb index a49a610..2e3b16c 100644 --- a/lib/dedupe_requests/controller.rb +++ b/lib/dedupe_requests/controller.rb @@ -8,40 +8,53 @@ module DedupeRequests # # class ApplicationController < ActionController::Base # include DedupeRequests::Controller - # dedupe_requests only: %i[create update] + # dedupe_requests on: %i[create update] # end # - # Registers a SINGLE around_action that, for actions in the controller's - # de-dupe set, claims before the action runs and releases on any non-2xx - # response or raised exception. The set is an inherited class_attribute so - # subclasses can replace (only:), extend (also:), or trim (skip:) it. + # Registers a SINGLE around_action that, for each guarded action, claims before + # the action runs and releases on a 4xx/5xx response or a raised exception + # (2xx/3xx keep the claim). The guarded actions and their per-action TTLs live + # in an inherited class_attribute map, so subclasses extend or trim it. module Controller extend ActiveSupport::Concern included do - class_attribute :dedupe_requests_actions, instance_accessor: false, default: nil - class_attribute :dedupe_requests_options, instance_accessor: false, default: {} + # Map of guarded action (Symbol) => TTL (Integer, or nil meaning "use the + # global config TTL"). Inherited and copy-on-write, so a subclass can add + # to or trim it without touching the parent. + class_attribute :dedupe_requests_action_ttls, instance_accessor: false, default: {} around_action :dedupe_requests_around end class_methods do - def dedupe_requests(only: nil, also: nil, skip: nil, **options) - inherited = dedupe_requests_actions || [] - new_set = - if only - Array(only).map(&:to_sym) - else - set = inherited.dup - set |= Array(also).map(&:to_sym) if also - set -= Array(skip).map(&:to_sym) if skip - set - end - self.dedupe_requests_actions = new_set.uniq - self.dedupe_requests_options = dedupe_requests_options.merge(options) unless options.empty? + # Guard the named actions. A `ttl:`, if given, applies to exactly the + # actions named in THIS call. Calls accumulate, so per-action TTLs are + # expressed by repeating the line: + # + # dedupe_requests on: %i[create update] # both, global TTL + # dedupe_requests on: [:create], ttl: 120 # create → 120 + # dedupe_requests on: [:update], ttl: 180 # update → 180 + # + # Re-naming an action overrides its TTL. Subclasses inherit the map and can + # add to it or remove from it (`skip:` / `skip_dedupe_requests`). + def dedupe_requests(on: nil, skip: nil, ttl: nil) + map = dedupe_requests_action_ttls.dup + + Array(on).each { |action| map[action.to_sym] = ttl } + Array(skip).each { |action| map.delete(action.to_sym) } + + self.dedupe_requests_action_ttls = map end - def skip_dedupe_requests(only: nil) - self.dedupe_requests_actions = (dedupe_requests_actions || []) - Array(only).map(&:to_sym) + def skip_dedupe_requests(on: nil) + map = dedupe_requests_action_ttls.dup + Array(on).each { |action| map.delete(action.to_sym) } + self.dedupe_requests_action_ttls = map + end + + # The set of guarded actions (the keys of the TTL map). + def dedupe_requests_actions + dedupe_requests_action_ttls.keys end end @@ -53,8 +66,11 @@ def dedupe_requests_around return end - ttl = self.class.dedupe_requests_options[:ttl] || DedupeRequests.config.ttl - result = dedupe_requests_guard.claim(request, ttl: ttl) + result = dedupe_requests_guard.claim( + request, + ttl: dedupe_requests_ttl_for(action_name), + caller_id: dedupe_requests_caller_id + ) case result.outcome when :duplicate @@ -82,19 +98,30 @@ def dedupe_requests_around def dedupe_requests_applies? return false unless DedupeRequests.config.enabled? - actions = self.class.dedupe_requests_actions - !actions.nil? && actions.include?(action_name.to_sym) + self.class.dedupe_requests_action_ttls.key?(action_name.to_sym) + end + + # Per-action TTL, falling back to the global config TTL. + def dedupe_requests_ttl_for(action) + self.class.dedupe_requests_action_ttls[action.to_sym] || DedupeRequests.config.ttl + end + + # Resolve the caller identity by handing the whole controller to the + # configured `caller_id` callable (so it can use current_user, a header, etc.). + def dedupe_requests_caller_id + DedupeRequests.config.caller_id&.call(self) end def dedupe_requests_guard @dedupe_requests_guard ||= DedupeRequests::Guard.new(DedupeRequests.config) end - # A request didn't complete successfully → free the fingerprint so a genuine - # retry isn't blocked. Only a 2xx keeps the fingerprint for the full TTL. + # Keep the fingerprint when the request was handled — a 2xx, or a 3xx + # redirect (the Post/Redirect/Get pattern is a *successful* create) — so a + # later duplicate is still blocked for the full TTL. Only a 4xx/5xx (or a + # raised exception) releases it, so a genuinely failed request can be retried. def dedupe_requests_release?(status) - code = status.to_i - code < 200 || code >= 300 + status.to_i >= 400 end def dedupe_requests_render_conflict diff --git a/lib/dedupe_requests/fingerprint.rb b/lib/dedupe_requests/fingerprint.rb index 7ec10d8..2aad5e8 100644 --- a/lib/dedupe_requests/fingerprint.rb +++ b/lib/dedupe_requests/fingerprint.rb @@ -2,30 +2,47 @@ require "digest" +begin + require "openssl" +rescue LoadError + # Ruby built without OpenSSL — the digests fall back to the stdlib Digest below. +end + module DedupeRequests # Computes a stable fingerprint for a request. # # The fingerprint covers: caller_id + verb + path + query string + body. # Time is NOT part of the fingerprint — the dedup window is the Redis TTL. module Fingerprint - ALGORITHMS = { - sha256: ->(data) { Digest::SHA256.hexdigest(data) }, - sha1: ->(data) { Digest::SHA1.hexdigest(data) }, - sha512: ->(data) { Digest::SHA512.hexdigest(data) }, - md5: ->(data) { Digest::MD5.hexdigest(data) } + # Prefer OpenSSL (uses the CPU's SHA instructions — several times faster on + # large bodies), falling back to the stdlib Digest when OpenSSL is unavailable + # or has the algorithm disabled (e.g. MD5 under FIPS). Output is identical. + HASHERS = { + sha256: ["SHA256", Digest::SHA256], + sha1: ["SHA1", Digest::SHA1], + sha512: ["SHA512", Digest::SHA512], + md5: ["MD5", Digest::MD5] }.freeze + ALGORITHMS = HASHERS.transform_values do |(openssl_name, digest_class)| + lambda do |data| + OpenSSL::Digest.hexdigest(openssl_name, data) + rescue StandardError + digest_class.hexdigest(data) + end + end.freeze + module_function - def for_request(request, config) + def for_request(request, config, caller_id: nil) return config.fingerprint.call(request) if config.fingerprint parts = [ - caller_id(request, config).to_s, + caller_id.to_s, request.request_method.to_s, request.path.to_s, request.query_string.to_s, - body(request, config) + body(request) ] digest(parts, config.digest) end @@ -33,7 +50,10 @@ def for_request(request, config) # Length-prefixes each field so a value cannot "shift" across a field # boundary and collide with a different set of fields. def digest(parts, algorithm = :sha256) - data = Array(parts).map { |part| s = part.to_s; "#{s.bytesize}:#{s}" }.join + data = Array(parts).map do |part| + s = part.to_s + "#{s.bytesize}:#{s}" + end.join resolve(algorithm).call(data) end @@ -45,18 +65,13 @@ def resolve(algorithm) end end - def caller_id(request, config) - config.caller_id ? config.caller_id.call(request) : nil - end - - def body(request, config) - raw = (request.respond_to?(:raw_post) ? request.raw_post : read_and_rewind(request)).to_s - cap = config.max_body_bytes - cap ? raw.byteslice(0, cap).to_s : raw + def body(request) + request.respond_to?(:raw_post) ? request.raw_post.to_s : read_and_rewind(request).to_s end def read_and_rewind(request) return "" unless request.respond_to?(:body) && request.body + data = request.body.read request.body.rewind if request.body.respond_to?(:rewind) data diff --git a/lib/dedupe_requests/guard.rb b/lib/dedupe_requests/guard.rb index 83fcc4b..4d86f37 100644 --- a/lib/dedupe_requests/guard.rb +++ b/lib/dedupe_requests/guard.rb @@ -11,18 +11,18 @@ def initialize(config) @config = config end - def claim(request, ttl: @config.ttl) + def claim(request, ttl: @config.ttl, caller_id: nil) return Result.new(:skip) unless @config.enabled? return Result.new(:skip) unless DedupeRequests::MUTATING_VERBS.include?(request.request_method.to_s) store = @config.store return Result.new(:skip) unless store - fingerprint = Fingerprint.for_request(request, @config) + fingerprint = Fingerprint.for_request(request, @config, caller_id: caller_id) token = store.claim(fingerprint, ttl: ttl) case token - when :error then Result.new(:skip) # Redis down → fail open + when :error then Result.new(:skip) # Redis down → fail open when false then Result.new(:duplicate, fingerprint) else Result.new(:claimed, fingerprint, token) end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index f07a8a9..fc73220 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -59,7 +59,11 @@ logger = double("logger") expect(logger).to receive(:warn).with(/redis error/) config.logger = logger - config.redis = Class.new { def with; raise "down"; end }.new + config.redis = Class.new do + def with + raise "down" + end + end.new expect(config.store.claim("fp", ttl: 1)).to eq(:error) end @@ -87,18 +91,27 @@ def request_with(headers: {}, cookies: {}) it "skips the Authorization check when the request has no get_header" do obj = Object.new - def obj.cookies = { "_app_session" => "ck" } + def obj.cookies + { "_app_session" => "ck" } + end expect(described_class::DEFAULT_CALLER_ID.call(obj)).to eq("ck") end it "returns nil when the request supports no cookies and has no auth" do obj = Object.new - def obj.get_header(_name) = nil + def obj.get_header(_name) + nil + end expect(described_class::DEFAULT_CALLER_ID.call(obj)).to be_nil end it "ignores cookies that are not a session cookie" do expect(described_class::DEFAULT_CALLER_ID.call(request_with(cookies: { "tracking" => "x" }))).to be_nil end + + it "reads from controller.request when given a controller" do + controller = Struct.new(:request).new(request_with(headers: { "HTTP_AUTHORIZATION" => "Bearer y" })) + expect(described_class::DEFAULT_CALLER_ID.call(controller)).to eq("Bearer y") + end end end diff --git a/spec/controller_spec.rb b/spec/controller_spec.rb index 906c9ba..7e18fb1 100644 --- a/spec/controller_spec.rb +++ b/spec/controller_spec.rb @@ -26,13 +26,18 @@ def self.around_action(*); end # concern registers here; tests invoke the method attr_reader :request, :response, :rendered - def initialize(action:, request:) + def initialize(action:, request:, caller: nil) @action_name = action @request = request + @caller = caller @response = FakeResponse.new @rendered = nil end + def current_caller + @caller + end + def action_name @action_name end @@ -68,52 +73,75 @@ def run(controller) ran end - describe "the de-dupe set DSL (only/also/skip)" do - it "only: sets the exact action list" do - expect(controller_class(only: %i[create update]).dedupe_requests_actions) + describe "the de-dupe set DSL (on/skip)" do + it "on: sets the exact action list" do + expect(controller_class(on: %i[create update]).dedupe_requests_actions) .to contain_exactly(:create, :update) end - it "also: adds to the inherited baseline" do - base = controller_class(only: %i[create update]) - sub = Class.new(base) { dedupe_requests also: %i[approve] } + it "on: in a subclass adds to the inherited set" do + base = controller_class(on: %i[create update]) + sub = Class.new(base) { dedupe_requests on: %i[approve] } expect(sub.dedupe_requests_actions).to contain_exactly(:create, :update, :approve) end it "skip: subtracts from the inherited baseline" do - base = controller_class(only: %i[create update]) + base = controller_class(on: %i[create update]) sub = Class.new(base) { dedupe_requests skip: %i[create] } expect(sub.dedupe_requests_actions).to contain_exactly(:update) end it "skip_dedupe_requests removes inherited actions" do - base = controller_class(only: %i[create update]) - sub = Class.new(base) { skip_dedupe_requests only: %i[update] } + base = controller_class(on: %i[create update]) + sub = Class.new(base) { skip_dedupe_requests on: %i[update] } expect(sub.dedupe_requests_actions).to contain_exactly(:create) end it "does not mutate the parent's set when a subclass adjusts it" do - base = controller_class(only: %i[create update]) + base = controller_class(on: %i[create update]) Class.new(base) { dedupe_requests skip: %i[create] } expect(base.dedupe_requests_actions).to contain_exactly(:create, :update) end + + it "treats skip: of a never-guarded action as a harmless no-op (across inheritance)" do + app = Class.new(FakeController) { dedupe_requests on: [:create] } + sub = Class.new(app) { dedupe_requests skip: [:update] } # update was never guarded + + expect(sub.dedupe_requests_actions).to contain_exactly(:create) + + # create is still deduped in the subclass — the stray skip didn't break it + run(sub.new(action: "create", request: req)) + dup = sub.new(action: "create", request: req) + expect(run(dup)).to be(false) + end end describe "around behavior" do it "runs the action and does nothing for an action outside the set" do - controller = controller_class(only: %i[create]).new(action: "index", request: req) + controller = controller_class(on: %i[create]).new(action: "index", request: req) expect(run(controller)).to be(true) expect(controller.rendered).to be_nil end + it "does not dedupe an action removed with skip: (no dedupe at all)" do + klass = Class.new(FakeController) do + dedupe_requests on: %i[create update] + dedupe_requests skip: [:create] + end + run(klass.new(action: "create", request: req)) + dup = klass.new(action: "create", request: req) + expect(run(dup)).to be(true) # create was skipped → never deduped, runs again + expect(dup.rendered).to be_nil + end + it "processes the first request" do - controller = controller_class(only: %i[create]).new(action: "create", request: req) + controller = controller_class(on: %i[create]).new(action: "create", request: req) expect(run(controller)).to be(true) expect(controller.rendered).to be_nil end it "rejects an identical duplicate with 409 in enforce mode and skips the action" do - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) run(klass.new(action: "create", request: req)) dup = klass.new(action: "create", request: req) @@ -125,7 +153,7 @@ def run(controller) it "lets a duplicate through (no 409) in observe mode" do DedupeRequests.config.mode = :observe - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) run(klass.new(action: "create", request: req)) dup = klass.new(action: "create", request: req) @@ -139,7 +167,7 @@ def run(controller) DedupeRequests.config.on_duplicate_detected = ->(_info) { events << :detected } DedupeRequests.config.on_duplicate_rejected = ->(_info) { events << :rejected } - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) run(klass.new(action: "create", request: req)) # first → claimed, no hook dup = klass.new(action: "create", request: req) @@ -149,7 +177,7 @@ def run(controller) end it "releases the fingerprint on a non-2xx response so a retry is allowed" do - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) first = klass.new(action: "create", request: req) first.send(:dedupe_requests_around) { first.response.status = 500 } @@ -159,7 +187,7 @@ def run(controller) end it "releases the fingerprint when the action raises" do - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) boom = klass.new(action: "create", request: req) expect { boom.send(:dedupe_requests_around) { raise "boom" } }.to raise_error("boom") @@ -168,18 +196,27 @@ def run(controller) end it "keeps the fingerprint on a 2xx so a real duplicate is still blocked" do - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) run(klass.new(action: "create", request: req)) # 200 by default dup = klass.new(action: "create", request: req) expect(run(dup)).to be(false) end + it "keeps the fingerprint on a 3xx redirect (Post/Redirect/Get) so a duplicate is still blocked" do + klass = controller_class(on: %i[create]) + first = klass.new(action: "create", request: req) + first.send(:dedupe_requests_around) { first.response.status = 303 } + + dup = klass.new(action: "create", request: req) + expect(run(dup)).to be(false) # 303 kept the claim → duplicate blocked + end + it "emits duplicate_detected and duplicate_rejected hooks" do events = [] DedupeRequests.config.on_duplicate_detected = ->(info) { events << [:detected, info[:action]] } DedupeRequests.config.on_duplicate_rejected = ->(info) { events << [:rejected, info[:action]] } - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) run(klass.new(action: "create", request: req)) run(klass.new(action: "create", request: req)) @@ -190,7 +227,7 @@ def run(controller) captured = nil DedupeRequests.config.on_duplicate_detected = ->(info) { captured = info } - klass = controller_class(only: %i[create]) + klass = controller_class(on: %i[create]) run(klass.new(action: "create", request: req)) run(klass.new(action: "create", request: req)) @@ -206,15 +243,19 @@ def run(controller) describe "edge cases" do it "processes the request (no 409) when the store reports redis down (fail open)" do - DedupeRequests.config.store = Class.new { def claim(*, **) = :error }.new - controller = controller_class(only: %i[create]).new(action: "create", request: req) + DedupeRequests.config.store = Class.new do + def claim(*_args, **_opts) + :error + end + end.new + controller = controller_class(on: %i[create]).new(action: "create", request: req) expect(run(controller)).to be(true) expect(controller.rendered).to be_nil end it "does nothing when mode is :off" do DedupeRequests.config.mode = :off - controller = controller_class(only: %i[create]).new(action: "create", request: req) + controller = controller_class(on: %i[create]).new(action: "create", request: req) expect(run(controller)).to be(true) expect(controller.rendered).to be_nil end @@ -239,9 +280,93 @@ def release(*, **) end.new DedupeRequests.config.store = spy - run(controller_class(only: %i[create], ttl: 30).new(action: "create", request: req)) + run(controller_class(on: %i[create], ttl: 30).new(action: "create", request: req)) expect(spy.ttl).to eq(30) end + + it "resolves caller_id from the controller and scopes dedupe per caller" do + DedupeRequests.config.caller_id = ->(controller) { controller.current_caller } + klass = controller_class(on: %i[create]) + + run(klass.new(action: "create", request: req, caller: "u1")) + same = klass.new(action: "create", request: req, caller: "u1") + expect(run(same)).to be(false) # same caller + body → duplicate + + other = klass.new(action: "create", request: req, caller: "u2") + expect(run(other)).to be(true) # different caller → independent + end + + it "works when caller_id is disabled (nil)" do + DedupeRequests.config.caller_id = nil + controller = controller_class(on: %i[create]).new(action: "create", request: req) + expect(run(controller)).to be(true) + end + end + + # Per-action TTL: each `dedupe_requests` line attaches its `ttl:` to exactly the + # actions it names; lines accumulate; resolution per action is its own TTL, then + # the global config.ttl. + describe "per-action TTL" do + # Captures the ttl the store actually receives for a given action. + def captured_ttl_for(klass, action) + spy = Class.new do + attr_reader :ttl + + def claim(_fingerprint, ttl:) + @ttl = ttl + "tok" + end + + def release(*, **) + true + end + end.new + DedupeRequests.config.store = spy + klass.new(action: action.to_s, request: req).send(:dedupe_requests_around) {} + spy.ttl + end + + # Top-level baseline: create/update guarded with TTL 90. + let(:app_controller) { Class.new(FakeController) { dedupe_requests on: %i[create update], ttl: 90 } } + + it "applies one line's TTL to every action named on that line" do + klass = Class.new(FakeController) { dedupe_requests on: %i[create update], ttl: 120 } + expect(captured_ttl_for(klass, :create)).to eq(120) + expect(captured_ttl_for(klass, :update)).to eq(120) + end + + it "gives each action its own TTL when declared on separate lines" do + klass = Class.new(FakeController) do + dedupe_requests on: [:create], ttl: 120 + dedupe_requests on: [:update], ttl: 180 + end + expect(captured_ttl_for(klass, :create)).to eq(120) + expect(captured_ttl_for(klass, :update)).to eq(180) + end + + it "inherits per-action TTLs from a parent controller" do + sub = Class.new(app_controller) + expect(captured_ttl_for(sub, :create)).to eq(90) + expect(captured_ttl_for(sub, :update)).to eq(90) + end + + it "lets a subclass change the TTL by re-declaring the actions" do + sub = Class.new(app_controller) { dedupe_requests on: %i[create update], ttl: 30 } + expect(captured_ttl_for(sub, :create)).to eq(30) + expect(captured_ttl_for(sub, :update)).to eq(30) + end + + it "lets a subclass override one action's TTL, leaving the others inherited" do + sub = Class.new(app_controller) { dedupe_requests on: [:update], ttl: 180 } + expect(captured_ttl_for(sub, :create)).to eq(90) # inherited + expect(captured_ttl_for(sub, :update)).to eq(180) # overridden + end + + it "falls back to the global config TTL when a line has no ttl" do + DedupeRequests.config.ttl = 120 + plain = Class.new(FakeController) { dedupe_requests on: [:create] } + expect(captured_ttl_for(plain, :create)).to eq(120) + end end end diff --git a/spec/fingerprint_spec.rb b/spec/fingerprint_spec.rb index eaa03e1..0533411 100644 --- a/spec/fingerprint_spec.rb +++ b/spec/fingerprint_spec.rb @@ -5,17 +5,19 @@ RSpec.describe DedupeRequests::Fingerprint do let(:config) { DedupeRequests::Configuration.new } - def req(method: "POST", path: "/orders", query: "", body: "{}", auth: nil) + def req(method: "POST", path: "/orders", query: "", body: "{}") RequestDouble.new( request_method: method, path: path, query_string: query, raw_post: body, - headers: auth ? { "HTTP_AUTHORIZATION" => auth } : {}, cookies: {} + headers: {}, cookies: {} ) end # A request that exposes a readable body IO instead of #raw_post. def body_request(io) Struct.new(:request_method, :path, :query_string, :body, :headers, :cookies, keyword_init: true) do - def get_header(name) = (headers || {})[name] + def get_header(name) + (headers || {})[name] + end end.new(request_method: "POST", path: "/x", query_string: "", body: io, headers: {}, cookies: {}) end @@ -48,22 +50,15 @@ def get_header(name) = (headers || {})[name] .not_to eq(described_class.for_request(req(query: "a=2"), config)) end - it "separates callers by identity (Authorization header)" do - expect(described_class.for_request(req(auth: "user-a"), config)) - .not_to eq(described_class.for_request(req(auth: "user-b"), config)) + it "separates callers by the caller_id" do + expect(described_class.for_request(req, config, caller_id: "user-a")) + .not_to eq(described_class.for_request(req, config, caller_id: "user-b")) end it "uses a full fingerprint override when configured" do config.fingerprint = ->(_request) { "FIXED" } expect(described_class.for_request(req, config)).to eq("FIXED") end - - it "honors a max_body_bytes cap (bodies sharing the capped prefix collide)" do - config.max_body_bytes = 3 - a = described_class.for_request(req(body: "abcXXX"), config) - b = described_class.for_request(req(body: "abcYYY"), config) - expect(a).to eq(b) - end end describe "body and caller handling" do @@ -82,14 +77,15 @@ def get_header(name) = (headers || {})[name] expect(described_class.for_request(body_request(nil), config)).to match(/\A[0-9a-f]{64}\z/) end - it "omits caller identity when caller_id is nil" do - config.caller_id = nil + it "omits caller identity when no caller_id is given" do expect(described_class.for_request(req, config)).to match(/\A[0-9a-f]{64}\z/) end it "reads a body that cannot be rewound" do no_rewind = Object.new - def no_rewind.read = "payload" + def no_rewind.read + "payload" + end expect(described_class.for_request(body_request(no_rewind), config)).to match(/\A[0-9a-f]{64}\z/) end @@ -121,5 +117,10 @@ def no_rewind.read = "payload" it "raises on an unknown algorithm" do expect { described_class.digest(%w[x], :nope) }.to raise_error(ArgumentError) end + + it "falls back to the stdlib Digest when OpenSSL can't compute the hash" do + allow(OpenSSL::Digest).to receive(:hexdigest).and_raise(RuntimeError) + expect(described_class.digest(%w[x], :sha256)).to eq(Digest::SHA256.hexdigest("1:x")) + end end end diff --git a/spec/guard_spec.rb b/spec/guard_spec.rb index 5eeb523..70a50a6 100644 --- a/spec/guard_spec.rb +++ b/spec/guard_spec.rb @@ -31,7 +31,11 @@ def req(method: "POST", body: "{}") end it "fails open (skip) when the store reports a redis error" do - config.store = Class.new { def claim(*, **) = :error }.new + config.store = Class.new do + def claim(*_args, **_opts) + :error + end + end.new expect(guard.claim(req).outcome).to eq(:skip) end diff --git a/spec/integration/rails_integration_spec.rb b/spec/integration/rails_integration_spec.rb index 4b9b3f9..7a9c007 100644 --- a/spec/integration/rails_integration_spec.rb +++ b/spec/integration/rails_integration_spec.rb @@ -24,7 +24,7 @@ class DummyApplicationController < ActionController::Base include DedupeRequests::Controller - dedupe_requests only: %i[create update] + dedupe_requests on: %i[create update] end class WidgetsController < DummyApplicationController diff --git a/spec/integration/rails_real_redis_spec.rb b/spec/integration/rails_real_redis_spec.rb index 413cb47..d50e793 100644 --- a/spec/integration/rails_real_redis_spec.rb +++ b/spec/integration/rails_real_redis_spec.rb @@ -20,12 +20,16 @@ class RealAppController < ActionController::Base include DedupeRequests::Controller - dedupe_requests only: %i[create flaky boom] + dedupe_requests on: %i[create flaky boom redirected] def create render json: { ok: true }, status: :created end + def redirected + redirect_to "/things", status: :see_other + end + def flaky render json: { error: true }, status: :unprocessable_entity end @@ -40,6 +44,7 @@ def boom post "/things" => "real_app#create" post "/flaky" => "real_app#flaky" post "/boom" => "real_app#boom" + post "/redirected" => "real_app#redirected" end def app @@ -81,4 +86,11 @@ def post_json(path) expect { post_json "/boom" }.to raise_error(/kaboom/) expect { post_json "/boom" }.to raise_error(/kaboom/) # raises again (not a 409) → released end + + it "keeps the fingerprint on a 3xx redirect (Post/Redirect/Get), so a duplicate IS blocked" do + post_json "/redirected" + expect(last_response.status).to eq(303) + post_json "/redirected" + expect(last_response.status).to eq(409) # 3xx kept the claim → duplicate rejected + end end diff --git a/spec/redis_store_spec.rb b/spec/redis_store_spec.rb index d1348b3..5e44cec 100644 --- a/spec/redis_store_spec.rb +++ b/spec/redis_store_spec.rb @@ -29,27 +29,44 @@ it "wraps a bare client so a connection pool also works" do pool = Class.new do - def initialize(redis) = @redis = redis - def with = yield @redis + def initialize(redis) + @redis = redis + end + + def with + yield @redis + end end.new(redis) pooled = described_class.new(pool, namespace: "t") expect(pooled.claim("fp", ttl: 60)).to be_a(String) end it "fails open (returns :error) when redis raises" do - boom = Class.new { def with; raise "redis down"; end }.new + boom = Class.new do + def with + raise "redis down" + end + end.new expect(described_class.new(boom).claim("fp", ttl: 60)).to eq(:error) end it "logs via the configured logger when redis raises" do logger = double("logger") expect(logger).to receive(:warn).with(/redis error/) - boom = Class.new { def with; raise "down"; end }.new + boom = Class.new do + def with + raise "down" + end + end.new expect(described_class.new(boom, logger: logger).claim("fp", ttl: 60)).to eq(:error) end it "release returns false (rescued) when redis raises" do - boom = Class.new { def with; raise "down"; end }.new + boom = Class.new do + def with + raise "down" + end + end.new expect(described_class.new(boom).release("fp", "tok")).to be(false) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b98cb11..cd599f5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true +# ActiveSupport < 7.1 references ::Logger without requiring it, so loading +# action_controller (in the integration specs) raises "uninitialized constant +# ActiveSupport::LoggerThreadSafeLevel::Logger" on older Rubies. Load it first. +require "logger" + require "simplecov" SimpleCov.start do add_filter "/spec/" enable_coverage :branch + minimum_coverage line: 100, branch: 100 end require "dedupe_requests" diff --git a/spec/support/fake_redis.rb b/spec/support/fake_redis.rb index 66d85b7..281f804 100644 --- a/spec/support/fake_redis.rb +++ b/spec/support/fake_redis.rb @@ -10,7 +10,7 @@ def initialize @store = {} end - def set(key, value, nx: false, ex: nil) + def set(key, value, nx: false, ex: nil) # rubocop:disable Lint/UnusedMethodArgument return false if nx && @store.key?(key) @store[key] = value