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 +4 -4
- data/.github/workflows/reviewdog.yaml +13 -3
- data/CHANGELOG.md +8 -0
- data/httpigeon.gemspec +1 -0
- data/lib/httpigeon/circuit_breaker/errors.rb +11 -0
- data/lib/httpigeon/circuit_breaker/fuse.rb +207 -0
- data/lib/httpigeon/circuit_breaker/fuse_config.rb +71 -0
- data/lib/httpigeon/circuit_breaker/memory_store.rb +105 -0
- data/lib/httpigeon/configuration.rb +31 -1
- data/lib/httpigeon/logger.rb +1 -1
- data/lib/httpigeon/middleware/circuit_breaker.rb +50 -0
- data/lib/httpigeon/request.rb +27 -42
- data/lib/httpigeon/response.rb +45 -0
- data/lib/httpigeon/version.rb +1 -1
- data/lib/httpigeon.rb +27 -1
- metadata +23 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fb08778292f9e4ec48be0c011bb0f1052ed0b9f1a545f3e12b01cc0230edc417
|
4
|
+
data.tar.gz: 5345b29ff264cf7e6717dc93d39a3afc3569388da9644d6babb21c67c817b2d2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
6
|
+
permissions:
|
7
|
+
contents: write
|
6
8
|
runs-on: ubuntu-latest
|
7
9
|
steps:
|
8
10
|
- name: Check out code
|
9
|
-
uses: actions/checkout@
|
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
|
-
|
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,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,
|
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
|
data/lib/httpigeon/logger.rb
CHANGED
@@ -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)
|
data/lib/httpigeon/request.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
80
|
+
{ 'Accept' => 'application/json' }
|
100
81
|
end
|
101
|
-
end
|
102
82
|
|
103
|
-
|
104
|
-
|
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
|
107
|
-
|
108
|
-
|
109
|
-
|
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
|
data/lib/httpigeon/version.rb
CHANGED
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.
|
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-
|
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.
|
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
|