Class: Idempo

Inherits:
Object
  • Object
show all
Defined in:
lib/idempo.rb,
lib/idempo/version.rb

Defined Under Namespace

Modules: RequestFingerprint Classes: ActiveRecordBackend, ConcurrentRequest, ConcurrentRequestErrorApp, Error, MalformedIdempotencyKey, MalformedKeyErrorApp, MemoryBackend, MemoryLock, RedisBackend, ResponseStore

Constant Summary collapse

DEFAULT_TTL_SECONDS =
30
SAVED_RESPONSE_BODY_SIZE_LIMIT =
4 * 1024 * 1024
VERSION =
"1.2.2"

Instance Method Summary collapse

Constructor Details

#initialize(app, backend: MemoryBackend.new, malformed_key_error_app: MalformedKeyErrorApp, compute_fingerprint_via: RequestFingerprint, concurrent_request_error_app: ConcurrentRequestErrorApp, persist_for_seconds: DEFAULT_TTL_SECONDS) ⇒ Idempo

Returns a new instance of Idempo.



32
33
34
35
36
37
38
39
# File 'lib/idempo.rb', line 32

def initialize(app, backend: MemoryBackend.new, malformed_key_error_app: MalformedKeyErrorApp, compute_fingerprint_via: RequestFingerprint, concurrent_request_error_app: ConcurrentRequestErrorApp, persist_for_seconds: DEFAULT_TTL_SECONDS)
  @backend = backend
  @app = app
  @concurrent_request_error_app = concurrent_request_error_app
  @malformed_key_error_app = malformed_key_error_app
  @fingerprint_calculator = compute_fingerprint_via
  @persist_for_seconds = persist_for_seconds.to_i
end

Instance Method Details

#call(env) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/idempo.rb', line 41

def call(env)
  req = Rack::Request.new(env)
  return @app.call(env) if request_verb_idempotent?(req)
  return @app.call(env) unless (idempotency_key_header = extract_idempotency_key_from(env))

  # The RFC requires that the Idempotency-Key header value is enclosed in quotes
  idempotency_key_header_value = unquote(idempotency_key_header)
  raise MalformedIdempotencyKey if idempotency_key_header_value == ""

  request_key = @fingerprint_calculator.call(idempotency_key_header_value, req)

  @backend.with_idempotency_key(request_key) do |store|
    if (stored_response = store.lookup)
      Measurometer.increment_counter("idempo.responses_served_from", 1, from: "store")
      return from_persisted_response(stored_response)
    end

    status, headers, body = @app.call(env)

    expires_in_seconds = (headers.delete("X-Idempo-Persist-For-Seconds") || @persist_for_seconds).to_i

    # In some cases `body` could respond to to_ary. In this case, we don't need to call .close on body afterwards.
    #
    # @see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-body-
    body = body.to_ary if rack_v3? && body.respond_to?(:to_ary)

    if response_may_be_persisted?(status, headers, body)
      # Body is replaced with a cached version since a Rack response body is not rewindable
      marshaled_response, body = serialize_response(status, headers, body)
      store.store(data: marshaled_response, ttl: expires_in_seconds)
    end

    Measurometer.increment_counter("idempo.responses_served_from", 1, from: "freshly-generated")
    [status, headers, body]
  end
rescue MalformedIdempotencyKey
  Measurometer.increment_counter("idempo.responses_served_from", 1, from: "malformed-idempotency-key")
  @malformed_key_error_app.call(env)
rescue ConcurrentRequest
  Measurometer.increment_counter("idempo.responses_served_from", 1, from: "conflict-concurrent-request")
  @concurrent_request_error_app.call(env)
end