Module: ConcernsOnRails::Controllers::Idempotentable
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/concerns_on_rails/controllers/idempotentable.rb
Overview
Stripe-style ‘Idempotency-Key` support for mutating endpoints, with a store-agnostic, injectable backend. The first request with a key executes the action and caches the rendered response; a retry with the same key replays the cached response instead of re-running the action; a concurrent duplicate while the first is still in flight is halted with 409.
class PaymentsController < ApplicationController
include ConcernsOnRails::Controllers::Idempotentable
self.idempotency_store = Rails.cache # must support #read / #write(expires_in:, unless_exist:) / #delete
idempotent_actions :create, ttl: 24.hours, required: true
end
Lifecycle per key (scoped to controller#action, so the same client key on different endpoints never collides):
* claim won -> action runs; 2xx-4xx responses are cached for `ttl:`;
5xx responses and raised exceptions release the claim so
the client can retry.
* done -> the cached status/body/content type is replayed with
`X-Idempotency-Replayed: true`.
* in flight -> 409 with code "idempotency_conflict" and `Retry-After`.
* same key, different request payload -> 422 "idempotency_key_reuse"
(override `idempotency_fingerprint` to customize payload matching).
The claim is taken atomically via ‘write(…, unless_exist: true)` (memcached `add` / Redis `SET NX` through Rails.cache); a store without that atomicity is best-effort under concurrency. There is no in-process default store on purpose — configure one explicitly or the first keyed request raises ArgumentError. Note that responses rendered by `rescue_from` handlers bypass the around filter’s success path and are never cached.
IMPORTANT — callback ordering: declare halting filters (authentication, authorization, rate limiting) BEFORE including this module. A before_action that runs inside the around filter and halts (401/403) has its response cached and replayed for the full ‘ttl:` — the client cannot fix credentials and retry until it expires. Rails offers no reliable way for the around filter to detect a halted inner chain. Likewise set `lock_ttl:` above the worst-case duration of the slowest declared action — if the action outlasts the claim, a concurrent retry can win the expired key and execute the action a second time.
Defined Under Namespace
Modules: ClassMethods
Constant Summary collapse
- DEFAULT_HEADER =
"Idempotency-Key".freeze
- MAX_KEY_LENGTH =
255- IGNORED_FINGERPRINT_KEYS =
%w[controller action format].freeze
Instance Method Summary collapse
-
#enforce_idempotency ⇒ Object
around_action entry point.
-
#idempotency_error_response(message:, status:, code:) ⇒ Object
Single funnel for all error outcomes.
-
#idempotency_fingerprint ⇒ Object
Digest of the request payload, used to reject reusing one key for a different request.
-
#idempotency_key ⇒ Object
The raw key sent for the matched rule (nil when absent).
-
#replay_idempotent_response(record) ⇒ Object
Public override point for how a cached response is replayed.
Instance Method Details
#enforce_idempotency ⇒ Object
around_action entry point. Public so subclasses can override and specs can drive it directly with a block standing in for the action.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/concerns_on_rails/controllers/idempotentable.rb', line 90 def enforce_idempotency(&) rule = idempotency_rule_for_action return yield unless rule raw = read_idempotency_header(rule) return idempotency_handle_missing_key(rule, &) if raw.nil? key = raw.to_s.strip unless valid_idempotency_key?(key) return idempotency_error_response(message: "#{rule[:header]} is invalid (expected 1-#{MAX_KEY_LENGTH} characters).", status: :bad_request, code: "idempotency_key_invalid") end @idempotency_key = key run_with_idempotency(rule, key, &) end |
#idempotency_error_response(message:, status:, code:) ⇒ Object
Single funnel for all error outcomes. Uses Respondable’s render_error when available, otherwise the same inline envelope as Throttleable.
136 137 138 139 140 141 142 |
# File 'lib/concerns_on_rails/controllers/idempotentable.rb', line 136 def idempotency_error_response(message:, status:, code:) return unless respond_to?(:response) && response return render_error(message: , status: status, code: code) if respond_to?(:render_error) render json: { success: false, error: { message: , code: code } }, status: status end |
#idempotency_fingerprint ⇒ Object
Digest of the request payload, used to reject reusing one key for a different request. Public override point — e.g. for raw-body APIs:
def idempotency_fingerprint = Digest::SHA256.hexdigest(request.raw_post)
Override it for multipart endpoints too: an uploaded file stringifies with its object id, so retried uploads never match and 422 — digest stable parts instead (e.g. params&.original_filename).
118 119 120 121 122 |
# File 'lib/concerns_on_rails/controllers/idempotentable.rb', line 118 def idempotency_fingerprint raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h filtered = raw.reject { |k, _| IGNORED_FINGERPRINT_KEYS.include?(k.to_s) } Digest::SHA256.hexdigest(JSON.generate(idempotency_deep_sort(filtered))) end |
#idempotency_key ⇒ Object
The raw key sent for the matched rule (nil when absent). Handy for logging.
108 109 110 |
# File 'lib/concerns_on_rails/controllers/idempotentable.rb', line 108 def idempotency_key @idempotency_key end |
#replay_idempotent_response(record) ⇒ Object
Public override point for how a cached response is replayed.
125 126 127 128 129 130 131 132 |
# File 'lib/concerns_on_rails/controllers/idempotentable.rb', line 125 def replay_idempotent_response(record) return unless respond_to?(:response) && response emit_idempotency_replayed_header(true) = { body: record["body"], status: record["status"] } [:content_type] = record["content_type"] if record["content_type"] render() end |