Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#19): Manage exceptions by default and allow the gem's consumer to manage them by itself #20

Merged
merged 3 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ All changes to `grape-idempotency` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.1] - (Next)
## [1.1.0] - (Next)

### Fix

Expand All @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Feature

* [#20](https://github.com/jcagarcia/grape-idempotency/pull/20): Manage `Redis` exceptions by default and allow the gem's consumer to manage them by itself - [@jcagarcia](https://github.com/jcagarcia).
* Your contribution here.

## [1.0.0] - 2023-11-23
Expand Down
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Topics covered in this README:
- [Installation](#installation-)
- [Basic Usage](#basic-usage-)
- [How it works](#how-it-works-)
- [Making idempotency key header mandatory](#making-idempotency-key-header-mandatory-)
- [Making idempotency key header mandatory](#making-idempotency-key-header-mandatory-)
- [Redis Storage Connectivity Issue](#redis-storage-connectivity-issue)
- [Configuration](#configuration-)
- [Changelog](#changelog)
- [Contributing](#contributing)
Expand Down Expand Up @@ -81,7 +82,7 @@ Results are only saved if an API endpoint begins its execution. If incoming para

Additionally, this gem automatically appends the `Original-Request` header and the `Idempotency-Key` header to your API's response, enabling you to trace back to the initial request that generated that specific response.

## Making idempotency key header mandatory ⚠️
### Making idempotency key header mandatory ⚠️

For some endpoints, you want to enforce your consumers to provide idempotency key. So, when wrapping the code inside the `idempotent` method, you can mark it as `required`:

Expand Down Expand Up @@ -113,6 +114,37 @@ If the Idempotency-Key request header is missing for a idempotent operation requ

If you want to change the error message returned in this scenario, check [How to configure idempotency key missing error message](#mandatory_header_response) section.

### Redis Storage Connectivity Issue

By default, `Redis` exceptions are not handled by the `grape-idempotency` gem.

Therefore, if an exception arises while attempting to read, write or delete data from the `Redis` storage, the gem will re-raise the identical exception to your application. Thus, you will be responsible for handling it within your own code, such as:

```ruby
require 'grape'
require 'grape-idempotency'

class API < Grape::API
post '/payments' do
begin
idempotent do
status 201
Payment.create!({
amount: params[:amount]
})
end
rescue Redis::BaseError => e
error!("Redis error! Idempotency is very important here and we cannot continue.", 500)
end
end
end
end
```

If you want to avoid this functionality, and you want the gem handles the potential `Redis` exceptions, you have the option to configure the gem for handling these `Redis` exceptions. Please refer to the [manage_redis_exceptions](#manage_redis_exceptions) configuration property.

🚨 WARNING: If a `Redis` exception appears AFTER performing the wrapped code, nothing will be re-raised. The process will continue working and the response will be returned to the consumer of your API. However, a `409 Conflict` response can be returned to your consumer if it retried the same call with the same idempotency key. This is because the gem was not able to associate the response of the original request to the original idempotency key because those connectivity issues.

## Configuration 🪚

In addition to the storage aspect, you have the option to supply additional configuration details to tailor the gem to the specific requirements of your project.
Expand Down Expand Up @@ -195,6 +227,21 @@ I, [2023-11-23T22:41:39.148523 #1] DEBUG -- : [my-own-prefix] Request has been
I, [2023-11-23T22:41:39.148537 #1] DEBUG -- : [my-own-prefix] Returning the response from the original request.
```

### manage_redis_exceptions

By default, the `grape-idempotency` gem is configured to re-raise `Redis` exceptions.

If you want to delegate the `Redis` exception management into the gem, you can configure it using the `manage_redis_exceptions` configuration property.

```ruby
Grape::Idempotency.configure do |c|
c.storage = @storage
c.manage_redis_exceptions = true
jcagarcia marked this conversation as resolved.
Show resolved Hide resolved
end
```

However, this approach carries a certain level of risk. In the case that `Redis` experiences an outage, the idempotent functionality will be lost, the endpoint will behave as no idempotent, and this issue may go unnoticed.

### conflict_error_response

When providing a `Idempotency-Key: <key>` header, this gem compares incoming parameters to those of the original request (if exists) and returns a `409 - Conflict` status code if they don't match, preventing accidental misuse. The response body returned by the gem looks like:
Expand Down
3 changes: 2 additions & 1 deletion grape-idempotency.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = '>= 2.6'

spec.add_runtime_dependency 'grape', '~> 1'
spec.add_runtime_dependency 'grape', '>= 1'
spec.add_runtime_dependency 'redis', '>= 4'

spec.add_development_dependency 'bundler'
spec.add_development_dependency 'rspec'
Expand Down
69 changes: 50 additions & 19 deletions lib/grape/idempotency.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'grape'
require 'redis'
require 'logger'
require 'securerandom'
require 'grape/idempotency/version'
Expand Down Expand Up @@ -33,21 +34,21 @@ def idempotent(grape, required: false, &block)
grape.error!(configuration.mandatory_header_response, 400) if required && !idempotency_key
return block.call if !idempotency_key

cached_request = get_from_cache(idempotency_key)
log(:debug, "Request has been found for the provided idempotency key => #{cached_request}") if cached_request
if cached_request && (cached_request["params"] != grape.request.params || cached_request["path"] != grape.request.path)
log(:debug, "Request has conflicts. Same params? => #{cached_request["params"] != grape.request.params}. Same path? => #{cached_request["path"] != grape.request.path}")
stored_request = get_from_storage(idempotency_key)
log(:debug, "Request has been found for the provided idempotency key => #{stored_request}") if stored_request
if stored_request && (stored_request["params"] != grape.request.params || stored_request["path"] != grape.request.path)
log(:debug, "Request has conflicts. Same params? => #{stored_request["params"] != grape.request.params}. Same path? => #{stored_request["path"] != grape.request.path}")
log(:debug, "Returning conflict error response.")
grape.error!(configuration.conflict_error_response, 422)
elsif cached_request && cached_request["processing"] == true
elsif stored_request && stored_request["processing"] == true
log(:debug, "Returning processing error response.")
grape.error!(configuration.processing_response, 409)
elsif cached_request
elsif stored_request
log(:debug, "Returning the response from the original request.")
grape.status cached_request["status"]
grape.header(ORIGINAL_REQUEST_HEADER, cached_request["original_request"])
grape.status stored_request["status"]
grape.header(ORIGINAL_REQUEST_HEADER, stored_request["original_request"])
grape.header(configuration.idempotency_key_header, idempotency_key)
return cached_request["response"]
return stored_request["response"]
end

log(:debug, "Previous request information has NOT been found for the provided idempotency key.")
Expand Down Expand Up @@ -76,26 +77,32 @@ def idempotent(grape, required: false, &block)

grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
grape.body response
rescue Redis::BaseError => e
raise
rescue => e
log(:debug, "An unexpected error was raised when performing the block.")
if !cached_request && !response
if !stored_request && !response
validate_config!
log(:debug, "Storing error response.")
original_request_id = get_request_id(grape.request.headers)
stored_key = store_error_request(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, e)
log(:debug, "Error response stored.")
grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
grape.header(configuration.idempotency_key_header, stored_key)
if stored_key
log(:debug, "Error response stored.")
grape.header(ORIGINAL_REQUEST_HEADER, original_request_id)
grape.header(configuration.idempotency_key_header, stored_key)
end
end
log(:debug, "Re-raising the error.")
raise
ensure
if !cached_request && response
if !stored_request && response
validate_config!
log(:debug, "Storing response.")
stored_key = store_request_response(idempotency_key, grape.request.path, grape.request.params, grape.status, original_request_id, response)
log(:debug, "Response stored.")
grape.header(configuration.idempotency_key_header, stored_key)
if stored_key
log(:debug, "Response stored.")
grape.header(configuration.idempotency_key_header, stored_key)
end
end
end

Expand All @@ -113,6 +120,9 @@ def update_error_with_rescue_from_result(error, status, response)

store_request_response(idempotency_key, path, params, status, original_request_id, response)
storage.del(stored_error[:error_key])
rescue Redis::BaseError => e
log(:error, "Storage error => #{e.message} - #{e}")
nil
end

private
Expand All @@ -122,7 +132,10 @@ def validate_config!
end

def valid_storage?
configuration.storage && configuration.storage.respond_to?(:set)
configuration.storage &&
configuration.storage.respond_to?(:get) &&
configuration.storage.respond_to?(:set) &&
configuration.storage.respond_to?(:del)
end

def get_idempotency_key(headers)
Expand All @@ -141,11 +154,15 @@ def get_request_id(headers)
request_id || "req_#{SecureRandom.hex}"
end

def get_from_cache(idempotency_key)
def get_from_storage(idempotency_key)
value = storage.get(key(idempotency_key))
return unless value

JSON.parse(value)
rescue Redis::BaseError => e
log(:error, "Storage error => #{e.message} - #{e}")
return if configuration.manage_redis_exceptions
raise
end

def store_processing_request(idempotency_key, path, params, request_id)
Expand All @@ -157,6 +174,9 @@ def store_processing_request(idempotency_key, path, params, request_id)
}

storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: true)
rescue Redis::BaseError => e
return true if configuration.manage_redis_exceptions
raise
end

def store_request_response(idempotency_key, path, params, status, request_id, response)
Expand All @@ -170,6 +190,9 @@ def store_request_response(idempotency_key, path, params, status, request_id, re

storage.set(key(idempotency_key), body.to_json, ex: configuration.expires_in, nx: false)

idempotency_key
rescue Redis::BaseError => e
log(:error, "Storage error => #{e.message} - #{e}")
idempotency_key
end

Expand All @@ -187,6 +210,9 @@ def store_error_request(idempotency_key, path, params, status, request_id, error

storage.set(error_key(idempotency_key), body, ex: 30, nx: false)

idempotency_key
rescue Redis::BaseError => e
log(:error, "Storage error => #{e.message} - #{e}")
idempotency_key
end

Expand All @@ -207,6 +233,9 @@ def get_error_request_for(error)
}
end
end.first
rescue Redis::BaseError => e
log(:error, "Storage error => #{e.message} - #{e}")
nil
end

def is_an_error?(response)
Expand Down Expand Up @@ -252,7 +281,8 @@ def configuration

class Configuration
attr_accessor :storage, :logger, :logger_level, :logger_prefix, :expires_in, :idempotency_key_header,
:request_id_header, :conflict_error_response, :processing_response, :mandatory_header_response
:request_id_header, :conflict_error_response, :processing_response, :mandatory_header_response,
:manage_redis_exceptions

class Error < StandardError; end

Expand All @@ -264,6 +294,7 @@ def initialize
@expires_in = 216_000
@idempotency_key_header = "idempotency-key"
@request_id_header = "x-request-id"
@manage_redis_exceptions = false
@conflict_error_response = {
"title" => "Idempotency-Key is already used",
"detail" => "This operation is idempotent and it requires correct usage of Idempotency Key. Idempotency Key MUST not be reused across different payloads of this operation."
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/idempotency/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Grape
module Idempotency
VERSION = '1.0.1'
VERSION = '1.1.0'
end
end
Loading