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 +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
|