From 7229164555fd9e1020b994c6e505e75e2e055e52 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Tue, 23 Jun 2026 22:54:31 -0700 Subject: [PATCH 1/5] Towards version 1.0.0 --- CHANGELOG.md | 6 ++ README.md | 87 +++++++++++++++++----- examples/config.ru | 16 ++-- lib/dedupe_requests/configuration.rb | 32 +++----- lib/dedupe_requests/controller.rb | 32 +++++++- lib/dedupe_requests/version.rb | 2 +- spec/configuration_spec.rb | 48 +----------- spec/controller_spec.rb | 46 +++++++++++- spec/integration/rails_integration_spec.rb | 11 ++- spec/integration/rails_real_redis_spec.rb | 7 +- 10 files changed, 187 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b3980..c5c9e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.0.pre2 (2026-06-17) + +### Changed +- **`caller_id` is now required and has no default.** The old default keyed on the `Authorization` header / session cookie — but **rotating tokens are unsafe to key on**, so that default was removed. Configure `caller_id` with a callable that returns a stable, non-secret identifier (a user id, a JWT `sub`, an API-client id). +- When `caller_id` is unset or resolves to `nil`, de-duplication is now **skipped** for that request (it's allowed through) and a warning is logged — instead of treating all unidentified callers as one identity (which could wrongly 409 a different caller's identical request). Return a fixed string from `caller_id` to dedupe globally. + ## 1.0.0.pre1 (2026-06-16) Initial release. See the [README](README.md) for full usage and configuration. diff --git a/README.md b/README.md index 8ea8278..f60797d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Automatic server-side de-duplication of inbound mutating Rails requests (POST / 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). -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). +One go-to solution for this used to be to require the client to provide an idempotency key together with the request, and then reject duplicate requests (requests that use a previous idempotency key). `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. @@ -32,6 +32,69 @@ GET and DELETE are never deduped. Time is not part of the fingerprint — the ti gem "dedupe_requests" ``` +## Configuration: Who's your caller? + +There is an important configuration we can not decide for you: **what identifies your caller?** + +APIs typically have different callers, and you need to configure a way we can establish a `caller_id` that identifies the unique caller for `dedupe_requests` to work properly. + +If you have end users, the caller is an individual user. +If you have a B2B application, the caller is probably your business partner. + +Make sure to configure the `caller_id` mechanism correctly. + +**There is no default — you must set `caller_id`.** If it's unset (or your callable returns `nil` for a request), `dedupe_requests` **skips de-duplication for that request** (it's allowed through) and logs a warning. That's deliberate: with no caller identity, two *different* callers sending the same payload would collide and the second would get a wrong 409. So de-duplication only kicks in once `caller_id` resolves to a value. + +> **⚠️ Do not use a raw bearer token, API key, or session id as the identity.** They are secret and they rotate — so the same caller would look like different callers (silently weakening de-duplication), and you'd be leaking a secret into the dedup layer. Derive a **stable, non-secret** identifier instead: a user id, a JWT `sub`, an API-client id. + +`caller_id` is a callable given the **controller** (reach the request with `controller.request`): + +```ruby +# config/initializers/dedupe_requests.rb +DedupeRequests.configure do |c| + c.caller_id = ->(controller) { controller.current_user&.id } +end +``` + +Here are common ways to identify the caller — read any of them through the `controller` and return it from your `caller_id` lambda (e.g. `->(controller) { controller.request.headers['X-Client-ID'] }`): + +### Directly: +* `current_user.id` in a customer-facing application + +### Custom Headers: (only trustworthy if authenticated) +* `request.headers['X-Client-ID']` +* `request.headers['X-Organization-Id']` +* `request.headers['X-Partner-Id']` + +### Indirectly: (tokens can rotate or have a nonce) +* `request.headers["X-API-Key"]` + `partner = ApiClient.find_by!(api_key: api_key)` + +* `request.headers["Authorization"]` — decode the JWT and key on a stable claim: + +```ruby +c.caller_id = ->(controller) do + claims = decode_jwt(controller.request.headers["Authorization"]) + claims["sub"] # or claims["partner_id"] +end +``` + +### Infrastructure-Provided Identity + +`request.headers['X-Authenticated-User']` +`request.headers['X-Forwarded-Client-Cert']` +`request.headers['X-Amzn-Oidc-Identity']` +`request.headers['X-Goog-Authenticated-User-Id']` + +### Network-Based Identity: (rare and finicky) +* `caller_ips.include?(request.remote_ip)` # if you know the IP ranges for each caller + +**Only one caller? Dedupe globally.** If your API has a single caller — or you want to de-duplicate across all callers regardless of who's calling — return a fixed value so every request shares one identity (this also suppresses the no-identity warning): + +```ruby +c.caller_id = ->(_) { "global" } +``` + ## Usage ### 1. Global defaults — an initializer @@ -98,29 +161,17 @@ You never specify HTTP verbs per action — the route already determines the ver ### 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. +⚠️ `caller_id` scopes de-duplication per caller, and it **must be customized and properly configured for your application** — see the **Configuration** section above. There is no default; if it resolves to `nil`, that request is not de-duplicated (and a warning is logged). ## Modes and safe rollout `mode` has three states: - `:off` — disabled; no fingerprinting, no storage. -- `:observe` — **shadow mode**: compute and store fingerprints and fire the metrics hooks, but never return a 409. Duplicates are detected and reported only. +- `:observe` — **shadow mode**: compute and store fingerprints and fire `on_duplicate_detected`, but never return a 409. Duplicates are detected and reported only. - `:enforce` — detect, store, and reject duplicates with a 409. -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`. +Recommended rollout on a live service: enable `:observe`, build a dashboard from the `on_duplicate_detected` hook, watch real volume for a week or two, then flip to `:enforce`. ## Observability @@ -133,7 +184,7 @@ DedupeRequests.configure do |c| 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. +Each hook receives `{ fingerprint:, controller:, action:, verb:, path: }`. `on_duplicate_detected` fires in both `observe` and `enforce`; `on_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. @@ -169,7 +220,7 @@ 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 **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. | +| `caller_id` | none (required) | Callable **given the controller**, returns a stable, non-secret per-caller identity (e.g. `->(c){ c.current_user&.id }`). No default — if unset or it returns `nil`, that request is not de-duplicated (and a warning is logged). | | `fingerprint` | `nil` | Callable **given the request**, returns the fingerprint string — fully overriding the default computation. | | `conflict_status` | `409` | Status returned for a rejected duplicate. | | `conflict_body` | structured errors | JSON body for a rejected duplicate. | diff --git a/examples/config.ru b/examples/config.ru index c7a8ef8..daf75ff 100644 --- a/examples/config.ru +++ b/examples/config.ru @@ -52,10 +52,12 @@ DedupeRequests.configure do |c| c.redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/15")) c.mode = ENV.fetch("DEDUPE_MODE", "enforce").to_sym c.ttl = GLOBAL_TTL - # caller_id is left at its default, which derives the caller identity from the - # request's Authorization header. The integration test sends a different - # `Authorization: Bearer ` per simulated caller, so the same payload from - # two different callers fingerprints differently and is NOT treated as a duplicate. + + # ⚠️ DEMO ONLY — uses the raw Authorization header as the caller_id to keep the + # test simple. Do NOT do this in production; see the README "Configuration" + # section for how to set a stable, non-secret caller_id. + # (Overridden below when DEDUPE_CUSTOM_CALLER_ID is set.) + c.caller_id = ->(controller) { controller.request.get_header("HTTP_AUTHORIZATION") } # Record the duplicate-notification hooks. on_duplicate_detected fires whenever a # duplicate is seen (observe AND enforce); on_duplicate_rejected fires only when a @@ -75,9 +77,9 @@ DedupeRequests.configure do |c| end # When asked, replace caller_id with a custom one that identifies the caller by - # an X-Api-Key header (ignoring the Authorization header the default would use), - # so the test can prove this callable is what drives the per-caller scoping. It - # also records that the hook was invoked. + # an X-Api-Key header (instead of the Authorization header above), so the test + # can prove this callable is what drives the per-caller scoping. It also records + # that the hook was invoked. if ENV["DEDUPE_CUSTOM_CALLER_ID"] == "1" c.caller_id = lambda do |controller| key = controller.request.get_header("HTTP_X_API_KEY") diff --git a/lib/dedupe_requests/configuration.rb b/lib/dedupe_requests/configuration.rb index 6992cfa..737398e 100644 --- a/lib/dedupe_requests/configuration.rb +++ b/lib/dedupe_requests/configuration.rb @@ -12,28 +12,18 @@ class Configuration }] }.freeze - # 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: + # Per-caller identity. There is NO default — you MUST configure `caller_id` + # with a callable that returns a stable, non-secret identifier for the caller + # (a user id, a JWT `sub`, an API-client id). Do NOT use a raw bearer token or + # API key: it's secret and it rotates, so the same caller would look like + # different callers and de-duplication would silently weaken. The callable is + # given the CONTROLLER, so it can read `current_user`, a helper, 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? - end - if request.respond_to?(:cookies) - request.cookies.each { |name, value| return value if name.to_s =~ /\A_.*_session\z/i } - end - nil - end - + # When `caller_id` is unset or returns nil, de-duplication is skipped for the + # request (and a warning is logged), rather than risk treating different callers + # as one. attr_accessor :redis, :ttl, :digest, :namespace, :caller_id, :fingerprint, :conflict_status, :logger, :on_duplicate_detected, :on_duplicate_rejected @@ -47,7 +37,7 @@ def initialize @ttl = 90 @digest = :sha256 @namespace = "dedupe_requests" - @caller_id = DEFAULT_CALLER_ID + @caller_id = nil @fingerprint = nil @conflict_status = 409 @logger = nil diff --git a/lib/dedupe_requests/controller.rb b/lib/dedupe_requests/controller.rb index 2e3b16c..1610b33 100644 --- a/lib/dedupe_requests/controller.rb +++ b/lib/dedupe_requests/controller.rb @@ -66,10 +66,28 @@ def dedupe_requests_around return end + # GET/DELETE are never deduped — bail out before resolving caller_id, so the + # caller_id callable only runs for the verbs we actually de-duplicate. + unless dedupe_requests_mutating_verb? + yield + return + end + + caller_id = dedupe_requests_caller_id + # Without a caller identity, every unidentified caller would share one + # fingerprint, so two genuinely-different requests with the same body would + # collide and the second would be wrongly rejected. Skip de-duplication in + # that case (let the request through) and warn, rather than risk a false 409. + if caller_id.nil? + dedupe_requests_warn_missing_caller_id + yield + return + end + result = dedupe_requests_guard.claim( request, ttl: dedupe_requests_ttl_for(action_name), - caller_id: dedupe_requests_caller_id + caller_id: caller_id ) case result.outcome @@ -112,6 +130,18 @@ def dedupe_requests_caller_id DedupeRequests.config.caller_id&.call(self) end + def dedupe_requests_mutating_verb? + DedupeRequests::MUTATING_VERBS.include?(request.request_method.to_s) + end + + # Loud on purpose: a missing caller identity silently weakens de-duplication, + # so we warn on every such request (via the configured logger, else stderr). + def dedupe_requests_warn_missing_caller_id + message = "[dedupe_requests] caller_id resolved to nil for #{controller_name}##{action_name} (#{request.request_method} #{request.path}); de-duplication skipped. Configure DedupeRequests.config.caller_id." + logger = DedupeRequests.config.logger + logger ? logger.warn(message) : warn(message) + end + def dedupe_requests_guard @dedupe_requests_guard ||= DedupeRequests::Guard.new(DedupeRequests.config) end diff --git a/lib/dedupe_requests/version.rb b/lib/dedupe_requests/version.rb index 6f380b6..6ec5deb 100644 --- a/lib/dedupe_requests/version.rb +++ b/lib/dedupe_requests/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module DedupeRequests - VERSION = "1.0.0.pre1" + VERSION = "1.0.0.pre2" end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index fc73220..53d665d 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -67,51 +67,7 @@ def with expect(config.store.claim("fp", ttl: 1)).to eq(:error) end - describe "DEFAULT_CALLER_ID" do - def request_with(headers: {}, cookies: {}) - RequestDouble.new( - request_method: "POST", path: "/x", query_string: "", raw_post: "", - headers: headers, cookies: cookies - ) - end - - it "uses the Authorization header when present" do - id = described_class::DEFAULT_CALLER_ID.call(request_with(headers: { "HTTP_AUTHORIZATION" => "Bearer z" })) - expect(id).to eq("Bearer z") - end - - it "falls back to a Rails-style session cookie" do - id = described_class::DEFAULT_CALLER_ID.call(request_with(cookies: { "_myapp_session" => "abc" })) - expect(id).to eq("abc") - end - - it "is nil when neither identity signal is present" do - expect(described_class::DEFAULT_CALLER_ID.call(request_with)).to be_nil - end - - it "skips the Authorization check when the request has no get_header" do - obj = Object.new - 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 - 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 + it "has no default caller_id (you must configure one)" do + expect(config.caller_id).to be_nil end end diff --git a/spec/controller_spec.rb b/spec/controller_spec.rb index 7e18fb1..821c185 100644 --- a/spec/controller_spec.rb +++ b/spec/controller_spec.rb @@ -59,7 +59,15 @@ def req(body: "{}") ) end - before { DedupeRequests.configure { |c| c.redis = FakeRedis.new } } + # A stable caller identity for the dedup tests (the default no longer reads the + # Authorization header). Tests that exercise per-caller scoping or the + # no-identity path override `config.caller_id` themselves. + before do + DedupeRequests.configure do |c| + c.redis = FakeRedis.new + c.caller_id = ->(_controller) { "tester" } + end + end # An isolated controller class per call, so class-level state doesn't leak. def controller_class(**dsl) @@ -297,9 +305,43 @@ def release(*, **) expect(run(other)).to be(true) # different caller → independent end - it "works when caller_id is disabled (nil)" do + it "skips de-duplication (does not enforce) when caller_id resolves to nil" do DedupeRequests.config.caller_id = nil + klass = controller_class(on: %i[create]) + + expect(run(klass.new(action: "create", request: req))).to be(true) + # A duplicate also runs: with no caller identity we skip dedup rather than + # risk a false 409 across different callers. + expect(run(klass.new(action: "create", request: req))).to be(true) + end + + it "warns via the configured logger when caller_id resolves to nil for a guarded mutating action" do + DedupeRequests.config.caller_id = nil + logger = instance_double(Logger, warn: nil) + DedupeRequests.config.logger = logger + + run(controller_class(on: %i[create]).new(action: "create", request: req)) + expect(logger).to have_received(:warn).with(/caller_id resolved to nil/) + end + + it "falls back to Kernel#warn when no logger is configured" do + DedupeRequests.config.caller_id = nil + DedupeRequests.config.logger = nil controller = controller_class(on: %i[create]).new(action: "create", request: req) + + expect(controller).to receive(:warn).with(/caller_id resolved to nil/) + run(controller) + end + + it "does not warn for a non-mutating verb even when caller_id is nil" do + DedupeRequests.config.caller_id = nil + get_req = RequestDouble.new( + request_method: "GET", path: "/orders", query_string: "", raw_post: "{}", + headers: {}, cookies: {} + ) + controller = controller_class(on: %i[create]).new(action: "create", request: get_req) + + expect(controller).not_to receive(:warn) expect(run(controller)).to be(true) end end diff --git a/spec/integration/rails_integration_spec.rb b/spec/integration/rails_integration_spec.rb index 7a9c007..73e18b0 100644 --- a/spec/integration/rails_integration_spec.rb +++ b/spec/integration/rails_integration_spec.rb @@ -58,11 +58,16 @@ def app DedupeRequests.configure do |c| c.redis = redis c.namespace = "test" + # The default no longer reads Authorization; configure it explicitly (the + # realistic pattern). Tests send `HTTP_AUTHORIZATION` to identify the caller. + c.caller_id = ->(controller) { controller.request.get_header("HTTP_AUTHORIZATION") } end end + # Default Authorization header so the default caller_id resolves an identity + # (dedup is skipped when it can't). Tests that vary the caller override it. def post_json(path, body, headers = {}) - post path, body, { "CONTENT_TYPE" => "application/json" }.merge(headers) + post path, body, { "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer test-caller" }.merge(headers) end it "processes the first POST normally" do @@ -100,9 +105,9 @@ def post_json(path, body, headers = {}) end it "dedupes PATCH (update) too" do - patch "/widgets/1", '{"x":1}', "CONTENT_TYPE" => "application/json" + patch "/widgets/1", '{"x":1}', "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer test-caller" expect(last_response.status).to eq(200) - patch "/widgets/1", '{"x":1}', "CONTENT_TYPE" => "application/json" + patch "/widgets/1", '{"x":1}', "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer test-caller" expect(last_response.status).to eq(409) end diff --git a/spec/integration/rails_real_redis_spec.rb b/spec/integration/rails_real_redis_spec.rb index d50e793..924ddf2 100644 --- a/spec/integration/rails_real_redis_spec.rb +++ b/spec/integration/rails_real_redis_spec.rb @@ -59,13 +59,18 @@ def app DedupeRequests.configure do |c| c.redis = redis c.namespace = "real_e2e" + # The default no longer reads Authorization; configure it explicitly. The + # post_json helper sends an HTTP_AUTHORIZATION header to identify the caller. + c.caller_id = ->(controller) { controller.request.get_header("HTTP_AUTHORIZATION") } end end after { redis.flushdb } + # Authorization header so the default caller_id resolves an identity (dedup is + # skipped when it can't). def post_json(path) - post path, "{}", "CONTENT_TYPE" => "application/json" + post path, "{}", "CONTENT_TYPE" => "application/json", "HTTP_AUTHORIZATION" => "Bearer test-caller" end it "claim survives a 2xx, so a real duplicate is rejected with 409" do From e87b550bdee52458efd117a51b3a1390f20f7a1b Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Tue, 23 Jun 2026 23:25:14 -0700 Subject: [PATCH 2/5] update --- dedupe_requests.gemspec | 1 + 1 file changed, 1 insertion(+) diff --git a/dedupe_requests.gemspec b/dedupe_requests.gemspec index 88d60a0..8abbe63 100644 --- a/dedupe_requests.gemspec +++ b/dedupe_requests.gemspec @@ -16,6 +16,7 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" spec.metadata["rubygems_mfa_required"] = "false" spec.files = Dir["lib/**/*.rb", "examples/**/*", "README.md", "CHANGELOG.md", "LICENSE.txt"] From f5b99e9ad3678940e1944ef40a0de0dd19b20c82 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Tue, 23 Jun 2026 23:25:52 -0700 Subject: [PATCH 3/5] version --- lib/dedupe_requests/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dedupe_requests/version.rb b/lib/dedupe_requests/version.rb index 6ec5deb..5f41411 100644 --- a/lib/dedupe_requests/version.rb +++ b/lib/dedupe_requests/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module DedupeRequests - VERSION = "1.0.0.pre2" + VERSION = "1.0.0" end From bfb6def2651f6313fa1b054003948a6dee7f695f Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Tue, 23 Jun 2026 23:29:39 -0700 Subject: [PATCH 4/5] update --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c9e9f..d093a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog -## 1.0.0.pre2 (2026-06-17) +## 1.0.0.pre (2026-06-24) -### Changed -- **`caller_id` is now required and has no default.** The old default keyed on the `Authorization` header / session cookie — but **rotating tokens are unsafe to key on**, so that default was removed. Configure `caller_id` with a callable that returns a stable, non-secret identifier (a user id, a JWT `sub`, an API-client id). +### Changes +- **`caller_id` is now required -- set it to a callable (a lambda) - and has no default.** The old default keyed on the `Authorization` header / session cookie — but **rotating tokens are unsafe to key on**, so that default was removed. Configure `caller_id` with a callable that returns a stable, non-secret identifier (a user id, a JWT `sub`, an API-client id). - When `caller_id` is unset or resolves to `nil`, de-duplication is now **skipped** for that request (it's allowed through) and a warning is logged — instead of treating all unidentified callers as one identity (which could wrongly 409 a different caller's identical request). Return a fixed string from `caller_id` to dedupe globally. ## 1.0.0.pre1 (2026-06-16) From c29d546da65344aacaa37c378c4aaa1d42542e8c Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Tue, 23 Jun 2026 23:30:03 -0700 Subject: [PATCH 5/5] update --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d093a3c..a30a1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog -## 1.0.0.pre (2026-06-24) +## 1.0.0 (2026-06-24) ### Changes -- **`caller_id` is now required -- set it to a callable (a lambda) - and has no default.** The old default keyed on the `Authorization` header / session cookie — but **rotating tokens are unsafe to key on**, so that default was removed. Configure `caller_id` with a callable that returns a stable, non-secret identifier (a user id, a JWT `sub`, an API-client id). +- **`caller_id` is now required -- set it to a callable (a lambda) - and has no default.** The old default keyed on the `Authorization` header / session cookie — but **rotating tokens are unsafe to key on**, so that default was removed. +Configure `caller_id` with a callable that returns a stable, non-secret identifier (a user id, a JWT `sub`, an API-client id). - When `caller_id` is unset or resolves to `nil`, de-duplication is now **skipped** for that request (it's allowed through) and a warning is logged — instead of treating all unidentified callers as one identity (which could wrongly 409 a different caller's identical request). Return a fixed string from `caller_id` to dedupe globally. ## 1.0.0.pre1 (2026-06-16)