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

Instance Method Details

#enforce_idempotencyObject

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: message, status: status, code: code) if respond_to?(:render_error)

  render json: { success: false, error: { message: message, code: code } }, status: status
end

#idempotency_fingerprintObject

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_keyObject

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)
  options = { body: record["body"], status: record["status"] }
  options[:content_type] = record["content_type"] if record["content_type"]
  render(options)
end