httpigeon 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 500fac14ff3591858c0547ea049891de72ffa58747e76ddb1f5faf3cd2a12ba8
4
- data.tar.gz: 2f6bce6c474bed8bd6cf9168b8f4271a7d4483145d89a5674a72ce6f83c88ac6
3
+ metadata.gz: fb08778292f9e4ec48be0c011bb0f1052ed0b9f1a545f3e12b01cc0230edc417
4
+ data.tar.gz: 5345b29ff264cf7e6717dc93d39a3afc3569388da9644d6babb21c67c817b2d2
5
5
  SHA512:
6
- metadata.gz: 114d33029253f8ebd4a9dac5e8815f59b1d8a369ab9be4e971d1711da8df4f24618885876902138bd4d7c4888dd89a7d108da8c82296a8d1c996071c5444141f
7
- data.tar.gz: fe8312258406cd6f78a162ce3f5f704afcb4091526083802a1bb724b759d5481ff859f885099ecbaacae888df72378e24c7cebb7c1c79704c4ba141b2a92aba8
6
+ metadata.gz: 3cd0f88eec378aafb85ae799aa070242e7c0caddd26cf21feaa01c50048f65c32e6c904734bf0994812b40e400e4e0e951b2c899f1991b28d3fc89b5aab4e1ee
7
+ data.tar.gz: 8a1dc3e68162de054e829e488f926a33848c77f63d6c7ea6f47b61f1c13f1d9aea928515c4c2856950ea398f94c55563e75502d03f80f5f6b6fefd876975d18d
@@ -1,19 +1,29 @@
1
1
  name: Reviewdog
2
2
  on: [pull_request]
3
+
3
4
  jobs:
4
5
  rubocop:
5
- name: rubocop
6
+ permissions:
7
+ contents: write
6
8
  runs-on: ubuntu-latest
7
9
  steps:
8
10
  - name: Check out code
9
- uses: actions/checkout@v3
11
+ uses: actions/checkout@v4
12
+ with:
13
+ ref: ${{ github.head_ref }}
10
14
  - uses: ruby/setup-ruby@v1
11
15
  with:
12
16
  ruby-version: 3.1
13
17
  - name: rubocop
14
18
  uses: reviewdog/action-rubocop@v2
15
19
  with:
20
+ rubocop_flags: -a
16
21
  rubocop_version: gemfile
17
22
  rubocop_extensions: rubocop-rspec:gemfile
18
23
  reporter: github-pr-check
19
- fail_on_error: true
24
+
25
+ - name: Auto Commit
26
+ uses: stefanzweifel/git-auto-commit-action@v5
27
+ with:
28
+ commit_message: Rubocop Auto Corrections
29
+ commit_user_name: Rubocop
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0](https://github.com/dailypay/httpigeon/compare/v2.1.0...v2.2.0) (2024-05-17)
4
+
5
+
6
+ ### Features
7
+
8
+ * Add more callbacks ([#38](https://github.com/dailypay/httpigeon/issues/38)) ([660cfba](https://github.com/dailypay/httpigeon/commit/660cfba63a8cdc2ff73764913426d38644aaf53a))
9
+ * **request:** Implement circuit breaking ([#33](https://github.com/dailypay/httpigeon/issues/33)) ([c25eab4](https://github.com/dailypay/httpigeon/commit/c25eab406d26b50da806d122eb73be0701b84c4e))
10
+
3
11
  ## [2.1.0](https://github.com/dailypay/httpigeon/compare/v2.0.1...v2.1.0) (2024-01-05)
4
12
 
5
13
 
data/httpigeon.gemspec CHANGED
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
36
36
  spec.add_development_dependency "rubocop", "~> 1.21"
37
37
  spec.add_development_dependency "rubocop-rspec", "~> 2.24"
38
38
  spec.add_development_dependency "pry", "~> 0.13.1"
39
+ spec.add_development_dependency "timecop", "~> 0.9.8"
39
40
 
40
41
  # For more information and examples about making a new gem, check out our
41
42
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -0,0 +1,11 @@
1
+ module HTTPigeon
2
+ module CircuitBreaker
3
+ class Error < StandardError; end
4
+
5
+ class CircuitOpenError < Error
6
+ def initialize(service_id)
7
+ super("Circuit open for service: #{service_id}")
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,207 @@
1
+ require_relative 'errors'
2
+ require_relative 'fuse_config'
3
+ require_relative 'memory_store'
4
+ require_relative '../middleware/circuit_breaker'
5
+
6
+ module HTTPigeon
7
+ module CircuitBreaker
8
+ class Fuse
9
+ STATE_OPEN = 'open'.freeze
10
+ STATE_HALF_OPEN = 'half_open'.freeze
11
+ STATE_CLOSED = 'closed'.freeze
12
+
13
+ def self.from_options(options)
14
+ new(FuseConfig.new(options))
15
+ end
16
+
17
+ attr_reader :service_id, :config, :store
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ @service_id = config.service_id.to_s
22
+ @store = CircuitBreaker::MemoryStore.new(config.sample_window)
23
+ @open_storage_key = "circuit:#{service_id}:#{STATE_OPEN}"
24
+ @half_open_storage_key = "circuit:#{service_id}:#{STATE_HALF_OPEN}"
25
+ @state_change_syncer = Mutex.new
26
+ end
27
+
28
+ def execute(request_id: nil)
29
+ @request_id = request_id
30
+
31
+ if open?
32
+ record_tripped!
33
+
34
+ config.open_circuit_handler.call(config.null_response, config.circuit_open_error)
35
+ else
36
+ begin
37
+ response = yield
38
+ server_maintenance_timeout = response.headers[config.maintenance_mode_header].to_i
39
+
40
+ if server_maintenance_timeout.positive?
41
+ record_failure!
42
+ open!(
43
+ {
44
+ expires_in: server_maintenance_timeout,
45
+ # for logging purposes. can't log expires_in because it might be overridden if greater than max
46
+ server_maintenance_timeout: server_maintenance_timeout
47
+ }
48
+ )
49
+
50
+ return config.open_circuit_handler.call(response, config.circuit_open_error)
51
+ end
52
+
53
+ record_success!
54
+ response
55
+ rescue Faraday::Error => e
56
+ record_failure! if e.response_status >= 500 || config.error_codes_watchlist.include?(e.response_status)
57
+
58
+ raise e
59
+ end
60
+ end
61
+ end
62
+
63
+ def open?
64
+ store.key?(open_storage_key)
65
+ end
66
+
67
+ def half_open?
68
+ store.key?(half_open_storage_key)
69
+ end
70
+
71
+ def failure_count
72
+ store.get(stat_storage_key(:failure)).to_i
73
+ end
74
+
75
+ def success_count
76
+ store.get(stat_storage_key(:success)).to_i
77
+ end
78
+
79
+ def tripped_count
80
+ store.get(stat_storage_key(:tripped)).to_i
81
+ end
82
+
83
+ def failure_rate
84
+ total_stats = success_count + failure_count + tripped_count
85
+
86
+ return 0.0 unless total_stats.positive?
87
+
88
+ (total_stats - success_count).to_f / total_stats
89
+ end
90
+
91
+ def reset!
92
+ state_change_syncer.synchronize { store.reset! }
93
+ end
94
+
95
+ private
96
+
97
+ attr_reader :open_storage_key, :half_open_storage_key, :state_change_syncer, :request_id
98
+
99
+ def failed_request?(response)
100
+ response.status.nil? || response.status >= 500 || config.error_codes_watchlist.include?(response.status)
101
+ end
102
+
103
+ def should_open?
104
+ return false if failure_count < config.min_failures_count
105
+
106
+ failure_count >= config.max_failures_count || failure_rate >= config.failure_rate_threshold
107
+ end
108
+
109
+ def close!(opts = {})
110
+ state_change_syncer.synchronize do
111
+ # We only close the circuit if there have been at least one successful request during the current sample window
112
+ return unless success_count.positive?
113
+
114
+ # For the circuit to be closable, it must NOT be open AND
115
+ # it must be currently half open (i.e half_open_storage_key must be true)
116
+ # Otherwise, we return early
117
+ return unless !open? && store.delete(half_open_storage_key)
118
+
119
+ # reset failures count for current sample window
120
+ # so that we can only trip the circuit if we reach the min failures threshold again
121
+ store.delete(stat_storage_key(:failure))
122
+ end
123
+
124
+ log_circuit_event('circuit_closed', STATE_CLOSED, opts)
125
+ config.on_circuit_closed.call(store.storage, config.to_h)
126
+ end
127
+
128
+ def open!(opts = {})
129
+ state_change_syncer.synchronize do
130
+ return if open?
131
+
132
+ trip!(type: :full, **opts)
133
+
134
+ # reset failures count for current sample window so that the circuit doesn't re-open immediately
135
+ # if a request fails while in half_open state
136
+ store.delete(stat_storage_key(:failure))
137
+ end
138
+
139
+ opts.delete(:expires_in) # don't log expires_in key as it may be overridden if greater than max
140
+ log_circuit_event('circuit_opened', STATE_OPEN, opts)
141
+ config.on_circuit_opened.call(store.storage, config.to_h)
142
+ end
143
+
144
+ def half_open!(opts = {})
145
+ state_change_syncer.synchronize do
146
+ return if open? || half_open?
147
+
148
+ trip!(type: :partial, **opts)
149
+ end
150
+
151
+ log_circuit_event('circuit_half_opened', STATE_HALF_OPEN, opts)
152
+ end
153
+
154
+ def trip!(type:, **opts)
155
+ if type == :full
156
+ store.set(open_storage_key, true, { expires_in: config.open_circuit_sleep_window }.merge(opts))
157
+ store.set(half_open_storage_key, true, { expires_in: config.sample_window }.merge(opts))
158
+ elsif type == :partial
159
+ store.set(half_open_storage_key, true, { expires_in: config.sample_window }.merge(opts))
160
+ end
161
+ end
162
+
163
+ def record_success!
164
+ record_stat(:success)
165
+
166
+ close! if half_open?
167
+ end
168
+
169
+ def record_failure!
170
+ record_stat(:failure)
171
+
172
+ open! if should_open? && (!half_open? || !open?)
173
+ half_open! if !half_open? && failure_count >= config.min_failures_count
174
+ end
175
+
176
+ def record_tripped!
177
+ record_stat(:tripped)
178
+ log_circuit_event('execution_skipped', STATE_OPEN)
179
+ end
180
+
181
+ def record_stat(outcome, value = 1)
182
+ store.increment(stat_storage_key(outcome), value, expires_in: config.sample_window)
183
+ end
184
+
185
+ def stat_storage_key(outcome)
186
+ "run_stat:#{service_id}:#{outcome}"
187
+ end
188
+
189
+ def log_circuit_event(event, status, payload = {})
190
+ return unless HTTPigeon.log_circuit_events
191
+
192
+ payload = {
193
+ event_type: "httpigeon.fuse.#{event}",
194
+ service_id: service_id,
195
+ request_id: request_id,
196
+ circuit_state: status,
197
+ success_count: success_count,
198
+ failure_count: failure_count,
199
+ failure_rate: failure_rate,
200
+ recorded_at: Time.now.to_i
201
+ }.merge(payload).compact
202
+
203
+ HTTPigeon.event_logger&.log(payload) || HTTPigeon.stdout_logger.log(1, payload.to_json)
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,71 @@
1
+ require 'faraday'
2
+
3
+ module HTTPigeon
4
+ module CircuitBreaker
5
+ class NullResponse < Faraday::Response
6
+ attr_reader :api_response, :exception
7
+
8
+ def initialize(response = nil, exception = nil)
9
+ @api_response = response
10
+ @exception = exception
11
+ super(status: 503, response_headers: response&.headers || {})
12
+ end
13
+ end
14
+
15
+ class FuseConfig
16
+ DEFAULT_MM_TIMEOUT_HEADER = 'X-Maintenance-Mode-Timeout'.freeze
17
+
18
+ attr_reader :max_failures_count,
19
+ :min_failures_count,
20
+ :failure_rate_threshold,
21
+ :sample_window,
22
+ :open_circuit_sleep_window,
23
+ :on_circuit_closed,
24
+ :on_circuit_opened,
25
+ :open_circuit_handler,
26
+ :error_codes_watchlist,
27
+ :maintenance_mode_header,
28
+ :service_id
29
+
30
+ def initialize(fuse_options = {})
31
+ @service_id = fuse_options[:service_id].presence || raise(ArgumentError, 'service_id is required')
32
+ @max_failures_count = fuse_options[:max_failures_count] || HTTPigeon.fuse_max_failures_count
33
+ @min_failures_count = fuse_options[:min_failures_count] || HTTPigeon.fuse_min_failures_count
34
+ @failure_rate_threshold = fuse_options[:failure_rate_threshold] || HTTPigeon.fuse_failure_rate_threshold
35
+ @sample_window = fuse_options[:sample_window] || HTTPigeon.fuse_sample_window
36
+ @open_circuit_sleep_window = fuse_options[:open_circuit_sleep_window] || HTTPigeon.fuse_open_circuit_sleep_window
37
+ @error_codes_watchlist = fuse_options[:error_codes_watchlist].to_a | HTTPigeon.fuse_error_codes_watchlist.to_a
38
+ @maintenance_mode_header = fuse_options[:maintenance_mode_header] || DEFAULT_MM_TIMEOUT_HEADER
39
+
40
+ @on_circuit_closed = HTTPigeon.fuse_on_circuit_closed
41
+ @on_circuit_opened = HTTPigeon.fuse_on_circuit_opened
42
+ @open_circuit_handler = if HTTPigeon.fuse_open_circuit_handler.respond_to?(:call)
43
+ HTTPigeon.fuse_open_circuit_handler
44
+ else
45
+ ->(api_response, exception) { null_response(api_response, exception) }
46
+ end
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ service_id: service_id,
52
+ max_failures_count: max_failures_count,
53
+ min_failures_count: min_failures_count,
54
+ failure_rate_threshold: failure_rate_threshold,
55
+ sample_window: sample_window,
56
+ open_circuit_sleep_window: open_circuit_sleep_window,
57
+ error_codes_watchlist: error_codes_watchlist,
58
+ maintenance_mode_header: maintenance_mode_header
59
+ }
60
+ end
61
+
62
+ def null_response(api_response = nil, exception = nil)
63
+ NullResponse.new(api_response, exception)
64
+ end
65
+
66
+ def circuit_open_error
67
+ CircuitOpenError.new(service_id)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,105 @@
1
+ module HTTPigeon
2
+ module CircuitBreaker
3
+ class MemoryStore
4
+ MAX_SAMPLE_WINDOW = 180
5
+
6
+ attr_reader :sample_window
7
+
8
+ def initialize(sample_window)
9
+ @storage = {}
10
+ @mutex = Mutex.new
11
+ @sample_window = [sample_window.to_i, MAX_SAMPLE_WINDOW].min
12
+ end
13
+
14
+ def get(key)
15
+ @mutex.synchronize { fetch_bucket(key)&.value }
16
+ end
17
+ alias_method :[], :get
18
+
19
+ def set(key, value, opts = {})
20
+ @mutex.synchronize do
21
+ flush(key)
22
+
23
+ @storage[key] = DataBucket.new(value, relative_expires_at(opts[:expires_in]))
24
+ value
25
+ end
26
+ end
27
+ alias_method :store, :set
28
+
29
+ def increment(key, value = 1, opts = {})
30
+ @mutex.synchronize do
31
+ existing_bucket = fetch_bucket(key)
32
+
33
+ if existing_bucket
34
+ existing_bucket.expires_at = relative_expires_at(opts[:expires_in])
35
+ existing_bucket.value += value
36
+ else
37
+ @storage[key] = DataBucket.new(value, relative_expires_at(opts[:expires_in]))
38
+ value
39
+ end
40
+ end
41
+ end
42
+ alias_method :incr, :increment
43
+
44
+ def key?(key)
45
+ @mutex.synchronize { !fetch_bucket(key).nil? }
46
+ end
47
+
48
+ def delete(key)
49
+ @mutex.synchronize { @storage.delete(key) }
50
+ end
51
+ alias_method :del, :delete
52
+
53
+ def reset!
54
+ @mutex.synchronize { @storage.clear }
55
+ end
56
+ alias_method :clear!, :reset!
57
+
58
+ def storage
59
+ @mutex.synchronize { @storage.dup }
60
+ end
61
+
62
+ private
63
+
64
+ def fetch_bucket(key)
65
+ bucket = @storage[key]
66
+
67
+ return unless bucket
68
+ @storage.delete(key) && return if bucket.expired?(current_time)
69
+
70
+ bucket
71
+ end
72
+
73
+ def flush(key)
74
+ bucket = @storage[key]
75
+
76
+ @storage.delete(key) if !!bucket&.expired?(current_time)
77
+ end
78
+
79
+ def current_time
80
+ Time.now.to_i
81
+ end
82
+
83
+ def relative_expires_at(expires_in)
84
+ current_time + [expires_in.to_i, sample_window].min
85
+ end
86
+ end
87
+
88
+ class DataBucket
89
+ attr_accessor :value, :expires_at
90
+
91
+ def initialize(value, expires_at)
92
+ @value = value
93
+ @expires_at = expires_at
94
+ end
95
+
96
+ def expired?(current_time = Time.now.to_i)
97
+ expires_at < current_time
98
+ end
99
+
100
+ def to_h
101
+ { value: value, expires_at: expires_at }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,6 +1,24 @@
1
1
  module HTTPigeon
2
2
  class Configuration
3
- attr_accessor :default_event_type, :default_filter_keys, :redactor_string, :log_redactor, :event_logger, :notify_all_exceptions, :exception_notifier, :auto_generate_request_id
3
+ attr_accessor :default_event_type,
4
+ :default_filter_keys,
5
+ :redactor_string,
6
+ :log_redactor,
7
+ :event_logger,
8
+ :notify_all_exceptions,
9
+ :exception_notifier,
10
+ :auto_generate_request_id,
11
+ :mount_circuit_breaker,
12
+ :log_circuit_events,
13
+ :fuse_error_codes_watchlist,
14
+ :fuse_on_circuit_opened,
15
+ :fuse_on_circuit_closed,
16
+ :fuse_open_circuit_handler,
17
+ :fuse_max_failures_count,
18
+ :fuse_min_failures_count,
19
+ :fuse_failure_rate_threshold,
20
+ :fuse_sample_window,
21
+ :fuse_open_circuit_sleep_window
4
22
 
5
23
  def initialize
6
24
  @default_event_type = 'http.outbound'
@@ -11,6 +29,18 @@ module HTTPigeon
11
29
  @auto_generate_request_id = true
12
30
  @notify_all_exceptions = false
13
31
  @exception_notifier = nil
32
+ @mount_circuit_breaker = false
33
+ @log_circuit_events = true
34
+
35
+ @fuse_error_codes_watchlist = []
36
+ @fuse_on_circuit_opened = ->(*_args) {}
37
+ @fuse_on_circuit_closed = ->(*_args) {}
38
+ @fuse_open_circuit_handler = nil
39
+ @fuse_max_failures_count = 10
40
+ @fuse_min_failures_count = 5
41
+ @fuse_failure_rate_threshold = 0.5
42
+ @fuse_sample_window = 60
43
+ @fuse_open_circuit_sleep_window = 30
14
44
  end
15
45
  end
16
46
  end
@@ -78,7 +78,7 @@ module HTTPigeon
78
78
  end
79
79
 
80
80
  def log_to_stdout(log_data)
81
- ::Logger.new($stdout).log(1, log_data.to_json)
81
+ HTTPigeon.stdout_logger.log(1, log_data.to_json)
82
82
  end
83
83
  end
84
84
  end
@@ -0,0 +1,50 @@
1
+ require 'faraday'
2
+
3
+ module HTTPigeon
4
+ module Middleware
5
+ class CircuitBreaker < Faraday::Middleware
6
+ class FailedRequestError < Faraday::Error; end
7
+
8
+ def initialize(app, fuse_config)
9
+ super(app)
10
+
11
+ @fuse_config = fuse_config
12
+ end
13
+
14
+ def on_complete(env)
15
+ return unless failed_request?(env.status)
16
+
17
+ raise FailedRequestError, response_values(env)
18
+ end
19
+
20
+ private
21
+
22
+ def failed_request?(response_status)
23
+ response_status.nil? || response_status >= 500 || @fuse_config.error_codes_watchlist.include?(response_status)
24
+ end
25
+
26
+ def response_values(env)
27
+ {
28
+ status: env.status,
29
+ headers: env.response_headers,
30
+ body: env.body,
31
+ request: {
32
+ method: env.method,
33
+ url: env.url,
34
+ url_path: env.url.path,
35
+ params: query_params(env),
36
+ headers: env.request_headers,
37
+ body: env.request_body
38
+ }
39
+ }
40
+ end
41
+
42
+ def query_params(env)
43
+ env.request.params_encoder ||= Faraday::Utils.default_params_encoder
44
+ env.params_encoder.decode(env.url.query)
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ Faraday::Response.register_middleware(circuit_breaker: HTTPigeon::Middleware::CircuitBreaker)
@@ -6,38 +6,39 @@ require_relative "middleware/httpigeon_logger"
6
6
 
7
7
  module HTTPigeon
8
8
  class Request
9
+ REQUEST_ID_HEADER = 'X-Request-Id'.freeze
10
+
9
11
  class << self
10
12
  def get(endpoint, query = {}, headers = {}, event_type = nil, log_filters = [])
11
13
  request = new(base_url: endpoint, headers: headers, event_type: event_type, log_filters: log_filters)
12
- parsed_response = request.run(method: :get, path: '', payload: query) do |req|
14
+ request.run(method: :get, path: '', payload: query) do |req|
13
15
  yield(req) if block_given?
14
16
  end
15
-
16
- HTTPigeon::Response.new(request, parsed_response, request.response)
17
17
  end
18
18
 
19
19
  def post(endpoint, payload, headers = {}, event_type = nil, log_filters = [])
20
20
  request = new(base_url: endpoint, headers: headers, event_type: event_type, log_filters: log_filters)
21
- parsed_response = request.run(method: :post, path: '', payload: payload) do |req|
21
+ request.run(method: :post, path: '', payload: payload) do |req|
22
22
  yield(req) if block_given?
23
23
  end
24
-
25
- HTTPigeon::Response.new(request, parsed_response, request.response)
26
24
  end
27
25
  end
28
26
 
29
- attr_reader :connection, :response, :parsed_response
27
+ attr_reader :connection, :response, :parsed_response, :base_url, :fuse
30
28
 
31
29
  delegate :status, :body, to: :response, prefix: true
32
30
 
33
- def initialize(base_url:, options: nil, headers: nil, adapter: nil, logger: nil, event_type: nil, log_filters: nil)
34
- @base_url = base_url
31
+ def initialize(base_url:, options: nil, headers: nil, adapter: nil, logger: nil, event_type: nil, log_filters: nil, fuse_config: nil)
32
+ @base_url = URI.parse(base_url)
35
33
 
36
34
  request_headers = default_headers.merge(headers.to_h)
35
+ fuse_config_opts = { service_id: @base_url.host }.merge(fuse_config.to_h)
36
+ @fuse = CircuitBreaker::Fuse.from_options(fuse_config_opts)
37
37
 
38
- base_connection = Faraday.new(url: base_url).tap do |config|
38
+ base_connection = Faraday.new(url: @base_url.to_s).tap do |config|
39
39
  config.headers.deep_merge!(request_headers)
40
40
  config.options.merge!(options.to_h)
41
+ config.response :circuit_breaker, fuse.config if HTTPigeon.mount_circuit_breaker
41
42
  config.response :httpigeon_logger, logger if logger.is_a?(HTTPigeon::Logger)
42
43
  end
43
44
 
@@ -60,53 +61,37 @@ module HTTPigeon
60
61
  connection.headers['Content-Type'] = 'application/json'
61
62
  end
62
63
 
63
- @response = connection.send(method, path, payload) do |request|
64
- yield(request) if block_given?
65
- end
64
+ connection.headers[REQUEST_ID_HEADER] = SecureRandom.uuid if HTTPigeon.auto_generate_request_id
66
65
 
67
- @parsed_response = parse_response || {}
66
+ raw_response = HTTPigeon.mount_circuit_breaker ? run_with_fuse(method, path, payload) : simple_run(method, path, payload)
67
+
68
+ @response = HTTPigeon::Response.new(self, raw_response)
68
69
  end
69
70
 
70
71
  private
71
72
 
72
73
  attr_reader :logger, :event_type, :log_filters
73
74
 
74
- def parse_response
75
- parsed_body = response_body.is_a?(String) ? JSON.parse(response_body) : response_body
76
- deep_with_indifferent_access(parsed_body)
77
- rescue JSON::ParserError
78
- response_body.presence
79
- end
80
-
81
- def deep_with_indifferent_access(obj)
82
- case obj
83
- when Hash
84
- obj.transform_values do |value|
85
- deep_with_indifferent_access(value)
86
- end.with_indifferent_access
87
- when Array
88
- obj.map { |item| deep_with_indifferent_access(item) }
89
- else
90
- obj
91
- end
92
- end
93
-
94
75
  def default_logger(event_type, log_filters)
95
76
  HTTPigeon::Logger.new(event_type: event_type, log_filters: log_filters)
96
77
  end
97
78
 
98
79
  def default_headers
99
- HTTPigeon.auto_generate_request_id ? { 'Accept' => 'application/json', 'X-Request-Id' => SecureRandom.uuid } : { 'Accept' => 'application/json' }
80
+ { 'Accept' => 'application/json' }
100
81
  end
101
- end
102
82
 
103
- class Response
104
- attr_reader :request, :parsed_response, :raw_response
83
+ def simple_run(method, path, payload)
84
+ connection.send(method, path, payload) do |request|
85
+ yield(request) if block_given?
86
+ end
87
+ end
105
88
 
106
- def initialize(request, parsed_response, raw_response)
107
- @request = request
108
- @parsed_response = parsed_response
109
- @raw_response = raw_response
89
+ def run_with_fuse(method, path, payload)
90
+ fuse.execute(request_id: connection.headers[REQUEST_ID_HEADER]) do
91
+ simple_run(method, path, payload) do |request|
92
+ yield(request) if block_given?
93
+ end
94
+ end
110
95
  end
111
96
  end
112
97
  end
@@ -0,0 +1,45 @@
1
+ module HTTPigeon
2
+ class Response
3
+ attr_reader :request, :parsed_response, :raw_response
4
+
5
+ delegate :to_h, :to_json, :with_indifferent_access, to: :parsed_response
6
+ delegate :status, :body, :env, to: :raw_response
7
+
8
+ def initialize(request, raw_response)
9
+ @request = request
10
+ @raw_response = raw_response
11
+
12
+ parse_response
13
+ end
14
+
15
+ def ==(other)
16
+ other == parsed_response || other.to_json == to_json || super
17
+ end
18
+
19
+ def [](key)
20
+ parsed_response[key]
21
+ end
22
+
23
+ private
24
+
25
+ def parse_response
26
+ parsed_body = body.is_a?(String) ? JSON.parse(body) : body
27
+ @parsed_response = deep_with_indifferent_access(parsed_body)
28
+ rescue JSON::ParserError
29
+ @parsed_response = body.presence || {}
30
+ end
31
+
32
+ def deep_with_indifferent_access(obj)
33
+ case obj
34
+ when Hash
35
+ obj.transform_values do |value|
36
+ deep_with_indifferent_access(value)
37
+ end.with_indifferent_access
38
+ when Array
39
+ obj.map { |item| deep_with_indifferent_access(item) }
40
+ else
41
+ obj
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,3 +1,3 @@
1
1
  module HTTPigeon
2
- VERSION = "2.1.0".freeze
2
+ VERSION = "2.2.0".freeze
3
3
  end
data/lib/httpigeon.rb CHANGED
@@ -5,6 +5,8 @@ require "httpigeon/version"
5
5
  require "httpigeon/log_redactor"
6
6
  require "httpigeon/logger"
7
7
  require "httpigeon/request"
8
+ require "httpigeon/response"
9
+ require "httpigeon/circuit_breaker/fuse"
8
10
 
9
11
  module HTTPigeon
10
12
  extend self
@@ -17,6 +19,8 @@ module HTTPigeon
17
19
  CLIENT_SECRET = "/(?'key'(client_?(s|S)?ecret=))(?'value'([^&$])*)/".freeze
18
20
  end
19
21
 
22
+ class InvalidConfigurationError < StandardError; end
23
+
20
24
  delegate :default_event_type,
21
25
  :default_filter_keys,
22
26
  :redactor_string,
@@ -25,19 +29,41 @@ module HTTPigeon
25
29
  :auto_generate_request_id,
26
30
  :notify_all_exceptions,
27
31
  :exception_notifier,
32
+ :mount_circuit_breaker,
33
+ :log_circuit_events,
34
+ :fuse_error_codes_watchlist,
35
+ :fuse_on_circuit_opened,
36
+ :fuse_on_circuit_closed,
37
+ :fuse_open_circuit_handler,
38
+ :fuse_max_failures_count,
39
+ :fuse_min_failures_count,
40
+ :fuse_failure_rate_threshold,
41
+ :fuse_sample_window,
42
+ :fuse_open_circuit_sleep_window,
43
+ :fuse_on_open_circuit,
28
44
  to: :configuration
29
45
 
30
46
  def configure
31
47
  @config = HTTPigeon::Configuration.new
32
48
 
33
- yield(@config)
49
+ yield(@config) if block_given?
50
+
51
+ validate_config(@config)
34
52
 
35
53
  @config.freeze
36
54
  end
37
55
 
56
+ def stdout_logger
57
+ @stdout_logger ||= ::Logger.new($stdout)
58
+ end
59
+
38
60
  private
39
61
 
40
62
  def configuration
41
63
  @configuration ||= @config || HTTPigeon::Configuration.new
42
64
  end
65
+
66
+ def validate_config(config)
67
+ raise InvalidConfigurationError, "Fuse sleep window: #{config.fuse_open_circuit_sleep_window} must be less than or equal to sample window: #{config.fuse_sample_window}" if fuse_open_circuit_sleep_window > fuse_sample_window
68
+ end
43
69
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: httpigeon
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - 2k-joker
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-04-14 00:00:00.000000000 Z
11
+ date: 2024-05-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -122,6 +122,20 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: 0.13.1
125
+ - !ruby/object:Gem::Dependency
126
+ name: timecop
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 0.9.8
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 0.9.8
125
139
  description: Client library that simplifies making and logging HTTP requests and responses.
126
140
  This library is built as an abstraction on top of the Faraday ruby client.
127
141
  email:
@@ -146,11 +160,17 @@ files:
146
160
  - bin/setup
147
161
  - httpigeon.gemspec
148
162
  - lib/httpigeon.rb
163
+ - lib/httpigeon/circuit_breaker/errors.rb
164
+ - lib/httpigeon/circuit_breaker/fuse.rb
165
+ - lib/httpigeon/circuit_breaker/fuse_config.rb
166
+ - lib/httpigeon/circuit_breaker/memory_store.rb
149
167
  - lib/httpigeon/configuration.rb
150
168
  - lib/httpigeon/log_redactor.rb
151
169
  - lib/httpigeon/logger.rb
170
+ - lib/httpigeon/middleware/circuit_breaker.rb
152
171
  - lib/httpigeon/middleware/httpigeon_logger.rb
153
172
  - lib/httpigeon/request.rb
173
+ - lib/httpigeon/response.rb
154
174
  - lib/httpigeon/version.rb
155
175
  homepage: https://github.com/dailypay/httpigeon
156
176
  licenses:
@@ -173,7 +193,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
173
193
  - !ruby/object:Gem::Version
174
194
  version: '0'
175
195
  requirements: []
176
- rubygems_version: 3.3.26
196
+ rubygems_version: 3.3.27
177
197
  signing_key:
178
198
  specification_version: 4
179
199
  summary: Simple, easy way to make and log HTTP requests and responses