httpigeon 2.1.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
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