Skip to content

Commit

Permalink
Merge pull request #50 from Eazybright/feature/retry-mechanism
Browse files Browse the repository at this point in the history
Feat: Add Exponential Retry Mechanism with Idempotency Headers
  • Loading branch information
unicodeveloper authored Jan 16, 2024
2 parents 625d848 + 3ff5d49 commit 151fec2
Show file tree
Hide file tree
Showing 8 changed files with 182 additions and 2 deletions.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ group :test do
gem "webmock"
end

gem "exponential-backoff"
gem 'mocha'
gem "rubocop", "~> 1.21"
gem 'uuid', '~> 2.3', '>= 2.3.9'

# gem 'pry-debugger-jruby'
12 changes: 12 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ GEM
crack (0.4.5)
rexml
diff-lcs (1.5.0)
exponential-backoff (0.0.4)
hashdiff (1.0.1)
httparty (0.21.0)
mini_mime (>= 1.0.0)
Expand All @@ -29,8 +30,12 @@ GEM
concurrent-ruby (~> 1.0)
json (2.6.3)
json (2.6.3-java)
macaddr (1.7.2)
systemu (~> 2.6.5)
mini_mime (1.1.2)
minitest (5.17.0)
mocha (2.1.0)
ruby2_keywords (>= 0.0.5)
multi_xml (0.6.0)
parallel (1.22.1)
parser (3.2.1.0)
Expand Down Expand Up @@ -66,9 +71,13 @@ GEM
rubocop-ast (1.27.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.11.0)
ruby2_keywords (0.0.5)
systemu (2.6.5)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.4.2)
uuid (2.3.9)
macaddr (~> 1.0)
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
Expand All @@ -83,10 +92,13 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
exponential-backoff
mocha
novu!
rake (~> 13.0)
rspec
rubocop (~> 1.21)
uuid (~> 2.3, >= 2.3.9)
webmock

BUNDLED WITH
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ To use the library, first initialize the client with your API token:
```ruby
require 'novu'

client = Novu::Client.new('YOUR_NOVU_API_TOKEN')
client = Novu::Client.new(access_token: 'YOUR_NOVU_API_TOKEN')
```

You can then call methods on the client to interact with the Novu API:
Expand Down Expand Up @@ -896,6 +896,44 @@ client.delete_topic('<insert-topic-key>')
client.subscriber_topic('<insert-topic-key>', '<insert-externalSubscriberId>')
```

### Idempotent Request

This SDK allows you to perform idempotent requests, that is safely retrying requests without accidentally performing the same operation twice.
To achieve this, provide an `idempotency_key` argument during initialization of the NOVU client. We strongly recommend that you use [UUID](https://datatracker.ietf.org/doc/html/rfc4122) format when you're generating your idempotency key.

```ruby
client = Novu::Client.new(
access_token: '<your-novu-api_key>',
idempotency_key: '<your-idempotency-key>'
)
```

### Exponential Retry Mechanism

You can configure this SDK to retry failed requests. This is done by using [Exponential backoff strategy](https://en.wikipedia.org/wiki/Exponential_backoff). It is a common algorithm for retrying requests. The retries gradually extend the waiting time until reaching a specific limit. The concept is to prevent overloading the server with simultaneous requests once it is back online, especially in cases of temporary downtime.

```ruby
client = Novu::Client.new(
access_token: '<your-novu-api_key>',
idempotency_key: '<your-idempotency-key>',
enable_retry: true, # it is disabled by default,
retry_config: {
max_retries: 3,
initial_delay: 4,
max_delay: 60
}
)
```

## The retry configuration is explained in the following table:
| Options | Detail | Default Value | Data Type |
| ------------- | -------------------------------------------------------------------| --------------- | --------- |
| max_retries | Specifies total number of retries to perform in case of failure | 1 | Integer |
| initial_delay | Specifies the minimum time to wait before retrying (in seconds) | 4 | Integer |
| max_delay | Specifies the maximum time to wait before retrying (in seconds) | 60 | Integer |
| enable_retry | enabling/disable the Exponential Retry mechanism | false | Boolean |


### For more information about these methods and their parameters, see the [API documentation](https://docs.novu.co/api-reference).

## Contributing
Expand Down
2 changes: 2 additions & 0 deletions lib/novu.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# frozen_string_literal: true

require "active_support/core_ext/hash"
require "exponential_backoff"
require "httparty"
require_relative "novu/version"
require_relative "novu/client"
require "uuid"

module Novu
# class Error < StandardError; end
Expand Down
29 changes: 29 additions & 0 deletions lib/novu/api/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module Novu
class Api
module Connection

def get(path, options = {})
request :get, path, options
end
Expand All @@ -25,8 +26,36 @@ def delete(path, options = {})

private

# Send API Request
#
# It applies exponential backoff strategy (if enabled) for failed requests.
# It also performs an idempotent request to safely retry requests without having duplication operations.
#
def request(http_method, path, options)

if http_method.to_s == 'post' || http_method.to_s == 'patch'
self.class.default_options[:headers].merge!({ "Idempotency-Key" => "#{@idempotency_key.to_s.strip}" })
end

response = self.class.send(http_method, path, options)

if ! [401, 403, 409, 500, 502, 503, 504].include?(response.code) && ! @enable_retry
response
elsif @enable_retry

if @retry_attempts < @max_retries
@retry_attempts += 1

@backoff.intervals.each do |interval|
sleep(interval)
request(http_method, path, options)
end
else
raise StandardError, "Max retry attempts reached"
end
else
response
end
end
end
end
Expand Down
37 changes: 36 additions & 1 deletion lib/novu/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,48 @@ class Client
base_uri "https://api.novu.co/v1"
format :json

def initialize(access_token = nil)
attr_accessor :enable_retry, :max_retries, :initial_delay, :max_delay, :idempotency_key

# @param `access_token` [String]
# @param `idempotency_key` [String]
# @param `enable_retry` [Boolean]
# @param `retry_config` [Hash]
# - max_retries [Integer]
# - initial_delay [Integer]
# - max_delay [Integer]
def initialize(access_token: nil, idempotency_key: nil, enable_retry: false, retry_config: {} )
raise ArgumentError, "Api Key cannot be blank or nil" if access_token.blank?

@idempotency_key = idempotency_key.blank? ? UUID.new.generate : idempotency_key

@enable_retry = enable_retry
@access_token = access_token.to_s.strip
@retry_attempts = 0

retry_config = defaults_retry_config.merge(retry_config)
@max_retries = retry_config[:max_retries]
@initial_delay = retry_config[:initial_delay]
@max_delay = retry_config[:max_delay]

self.class.default_options.merge!(headers: { "Authorization" => "ApiKey #{@access_token}" })

# Configure the exponential backoff - specifying initial and maximal delays, default is 4s and 60s respectively
if @enable_retry
@retry_config = retry_config
@backoff = ExponentialBackoff.new(@initial_delay, @max_delay)
end
rescue ArgumentError => e
puts "Error initializing Novu client: #{e.message}"
end

private

# @retun [Hash]
# - max_retries [Integer]
# - initial_delay [Integer]
# - max_delay [Integer]
def defaults_retry_config
{ max_retries: 1, initial_delay: 4, max_delay: 60 }
end
end
end
4 changes: 4 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
require_relative "../lib/novu"

RSpec.configure do |config|
config.expect_with :rspec, :test_unit
config.mock_with :mocha
config.pattern = '**/*.spec'

# # Enable flags like --only-failures and --next-failure
# config.example_status_persistence_file_path = ".rspec_status"

Expand Down
57 changes: 57 additions & 0 deletions test/unit/client_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require_relative "../../lib/novu"

describe Novu::Client do

let(:access_token) { "1234567890" }
let(:idempotency_key1) { "489e2e70-50be-013c-faf9-38e85644422a" }
let(:idempotency_key2) { "99893-50be-5d3c-k109-8920564sd22a" }

let(:body) {
{
firstName: "John",
lastName: "Doe"
}.to_json
}

let(:response_body) {
{
_id: "63f71b3ef067290fa669106d"
}.to_json
}

it "enables exponential retry mechanism" do
client = Novu::Client.new(
access_token: access_token,
idempotency_key: idempotency_key1,
enable_retry: true,
retry_config: { max_retries: 3 }
)
client.expects(:create_subscriber).returns(StandardError)

result = client.create_subscriber(body)
assert_equal(StandardError, result)
end

it "failed request not retried" do
client = Novu::Client.new(access_token: access_token)
client.expects(:create_subscriber).returns(status: 401)

result = client.create_subscriber(body)
assert_equal(401, result[:status])
end

it "performs idempotent request with no duplicate result" do

client = Novu::Client.new(access_token: access_token, idempotency_key: idempotency_key2)
client.expects(:create_subscriber).at_most(3).returns(status: 201, body: response_body)
result1 = client.create_subscriber(body)
result2 = client.create_subscriber(body)
result3 = client.create_subscriber(body)

assert_equal(result2[:body], result1[:body])
assert_equal(result2[:body], result3[:body])
assert_equal(result1[:body], result3[:body])
end
end

0 comments on commit 151fec2

Please sign in to comment.