Skip to content

Commit

Permalink
feat(#19): Manage exceptions by default and allow the gem's consumer …
Browse files Browse the repository at this point in the history
…to manage them by itself
  • Loading branch information
jcagarcia committed Dec 9, 2023
1 parent 9ca8359 commit 85e69e4
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 44 deletions.
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,14 @@ 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, the `grape-idempotency` gem is configured to handle potential `Redis` exceptions.

Therefore, if an exception arises while attempting to read, write or delete data from the `Redis` storage, the gem will safeguard against a crash by returning empty results. Consequently, the associated `block` will execute without considering potential prior calls made with the provided idempotency key. As a result, the expected idempotent behavior will not be enforced.

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

## 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 +204,44 @@ 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 handle potential `Redis` exceptions.
However, this approach carries a certain level of risk. In the case that `Redis` experiences an outage, the idempotent functionality will be lost, and this issue may go unnoticed.
If you prefer to take control of handling potential `Redis` exceptions, you have the option to configure the gem to abstain from managing Redis exceptions.
```ruby
Grape::Idempotency.configure do |c|
c.storage = @storage
c.manage_redis_exceptions = false
end
```
Now, if a `Redis` exception arises while attempting to utilize 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
```
### 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
73 changes: 54 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,10 @@ 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}")
return if configuration.manage_redis_exceptions
raise
end

private
Expand All @@ -122,7 +133,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 +155,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 +175,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 @@ -171,6 +192,10 @@ 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}")
return if configuration.manage_redis_exceptions
raise
end

def store_error_request(idempotency_key, path, params, status, request_id, error)
Expand All @@ -188,6 +213,10 @@ 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}")
return if configuration.manage_redis_exceptions
raise
end

def get_error_request_for(error)
Expand All @@ -207,6 +236,10 @@ def get_error_request_for(error)
}
end
end.first
rescue Redis::BaseError => e
log(:error, "Storage error => #{e.message} - #{e}")
return if configuration.manage_redis_exceptions
raise
end

def is_an_error?(response)
Expand Down Expand Up @@ -252,7 +285,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 +298,7 @@ def initialize
@expires_in = 216_000
@idempotency_key_header = "idempotency-key"
@request_id_header = "x-request-id"
@manage_redis_exceptions = true
@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

0 comments on commit 85e69e4

Please sign in to comment.