contractful 0.1.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 +7 -0
- data/API.md +142 -0
- data/ARCHITECTURE.md +134 -0
- data/AUTHORS +106 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +1290 -0
- data/SECURITY.md +67 -0
- data/bin/contractful +128 -0
- data/history.md +801 -0
- data/lib/contractful/abstract_response.rb +301 -0
- data/lib/contractful/circuit_breaker.rb +221 -0
- data/lib/contractful/connection_pool.rb +166 -0
- data/lib/contractful/contract/auth.rb +257 -0
- data/lib/contractful/contract/config.rb +93 -0
- data/lib/contractful/contract/definition.rb +295 -0
- data/lib/contractful/contract/dsl.rb +501 -0
- data/lib/contractful/contract/executor.rb +492 -0
- data/lib/contractful/contract/openapi_import.rb +1135 -0
- data/lib/contractful/contract/pagination.rb +218 -0
- data/lib/contractful/contract/param.rb +505 -0
- data/lib/contractful/contract/postman_import/auth.rb +39 -0
- data/lib/contractful/contract/postman_import/body.rb +87 -0
- data/lib/contractful/contract/postman_import/operation.rb +107 -0
- data/lib/contractful/contract/postman_import/scope.rb +37 -0
- data/lib/contractful/contract/postman_import/url.rb +80 -0
- data/lib/contractful/contract/postman_import.rb +164 -0
- data/lib/contractful/contract/response_field.rb +167 -0
- data/lib/contractful/contract/sequence.rb +432 -0
- data/lib/contractful/contract/swagger2/components.rb +84 -0
- data/lib/contractful/contract/swagger2/operations.rb +81 -0
- data/lib/contractful/contract/swagger2/parameters.rb +93 -0
- data/lib/contractful/contract/swagger2/references.rb +55 -0
- data/lib/contractful/contract/swagger2.rb +55 -0
- data/lib/contractful/contract.rb +85 -0
- data/lib/contractful/exceptions.rb +328 -0
- data/lib/contractful/notifications/active_support.rb +40 -0
- data/lib/contractful/notifications.rb +138 -0
- data/lib/contractful/pacer.rb +161 -0
- data/lib/contractful/params_array.rb +85 -0
- data/lib/contractful/payload.rb +340 -0
- data/lib/contractful/platform.rb +52 -0
- data/lib/contractful/rate_limit.rb +133 -0
- data/lib/contractful/raw_response.rb +70 -0
- data/lib/contractful/request.rb +1483 -0
- data/lib/contractful/resource.rb +189 -0
- data/lib/contractful/response.rb +109 -0
- data/lib/contractful/utils.rb +202 -0
- data/lib/contractful/version.rb +11 -0
- data/lib/contractful/windows/root_certs.rb +107 -0
- data/lib/contractful/windows.rb +9 -0
- data/lib/contractful.rb +233 -0
- metadata +244 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'http-cookie'
|
|
5
|
+
|
|
6
|
+
# Mixin giving responses (and raw responses) their shared accessors and
|
|
7
|
+
# behavior: status/header/body inspection, cookie handling and return
|
|
8
|
+
# semantics.
|
|
9
|
+
module Contractful
|
|
10
|
+
# Response behavior shared by Response and RawResponse: status and header
|
|
11
|
+
# inspection, cookie handling, redirection history and return semantics.
|
|
12
|
+
module AbstractResponse
|
|
13
|
+
# Headers that must never be forwarded to a different origin when
|
|
14
|
+
# following a redirect (RFC 9110 §15.4: a client should not forward
|
|
15
|
+
# credentials to a third party).
|
|
16
|
+
SENSITIVE_REDIRECT_HEADERS = %w[authorization proxy-authorization cookie].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :net_http_res, :request, :start_time, :end_time, :duration
|
|
19
|
+
|
|
20
|
+
def inspect
|
|
21
|
+
raise NotImplementedError.new('must override in subclass')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Logger from the request, potentially nil.
|
|
25
|
+
def log
|
|
26
|
+
request.log
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def log_response
|
|
30
|
+
return unless log
|
|
31
|
+
|
|
32
|
+
code = net_http_res.code
|
|
33
|
+
res_name = net_http_res.class.to_s.gsub(/\ANet::HTTP/, '')
|
|
34
|
+
content_type = (net_http_res['Content-type'] || '').gsub(/;.*\z/, '')
|
|
35
|
+
|
|
36
|
+
log << "# => #{code} #{res_name} | #{content_type} #{size} bytes, #{format('%.2f', duration)}s\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# HTTP status code
|
|
40
|
+
def code
|
|
41
|
+
@code ||= @net_http_res.code.to_i
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def history
|
|
45
|
+
@history ||= request.redirection_history || []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A hash of the headers, beautified with symbols and underscores.
|
|
49
|
+
# e.g. "Content-type" will become :content_type.
|
|
50
|
+
def headers
|
|
51
|
+
@headers ||= AbstractResponse.beautify_headers(@net_http_res.to_hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Parsed rate-limit information ({Contractful::RateLimit}), or nil when
|
|
55
|
+
# the server did not send any rate-limit headers. Memoized.
|
|
56
|
+
def rate_limit
|
|
57
|
+
@rate_limit ||= RateLimit.from_headers(headers)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# True when the response indicates rate limiting: a 429 status, or a
|
|
61
|
+
# server-reported remaining quota of zero.
|
|
62
|
+
def rate_limited?
|
|
63
|
+
return true if code == 429
|
|
64
|
+
|
|
65
|
+
rate_limit ? rate_limit.exhausted? : false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The raw headers.
|
|
69
|
+
def raw_headers
|
|
70
|
+
@raw_headers ||= @net_http_res.to_hash
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param [Net::HTTPResponse] net_http_res
|
|
74
|
+
# @param [Contractful::Request] request
|
|
75
|
+
# @param [Time] start_time
|
|
76
|
+
def response_set_vars(net_http_res, request, start_time)
|
|
77
|
+
@net_http_res = net_http_res
|
|
78
|
+
@request = request
|
|
79
|
+
@start_time = start_time
|
|
80
|
+
@end_time = Time.now
|
|
81
|
+
|
|
82
|
+
@duration = @start_time ? @end_time - @start_time : nil
|
|
83
|
+
|
|
84
|
+
# prime redirection history
|
|
85
|
+
history
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Hash of cookies extracted from response headers.
|
|
89
|
+
#
|
|
90
|
+
# NB: This will return only cookies whose domain matches this request, and
|
|
91
|
+
# may not even return all of those cookies if there are duplicate names.
|
|
92
|
+
# Use the full cookie_jar for more nuanced access.
|
|
93
|
+
#
|
|
94
|
+
# @see #cookie_jar
|
|
95
|
+
#
|
|
96
|
+
# @return [Hash]
|
|
97
|
+
#
|
|
98
|
+
def cookies
|
|
99
|
+
hash = {}
|
|
100
|
+
|
|
101
|
+
cookie_jar.cookies(@request.uri).each do |cookie|
|
|
102
|
+
hash[cookie.name] = cookie.value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
hash
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Cookie jar extracted from response headers.
|
|
109
|
+
#
|
|
110
|
+
# @return [HTTP::CookieJar]
|
|
111
|
+
#
|
|
112
|
+
def cookie_jar
|
|
113
|
+
return @cookie_jar if defined?(@cookie_jar) && @cookie_jar
|
|
114
|
+
|
|
115
|
+
jar = @request.cookie_jar.dup
|
|
116
|
+
headers.fetch(:set_cookie, []).each do |cookie|
|
|
117
|
+
jar.parse(cookie, @request.uri)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@cookie_jar = jar
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Return the default behavior corresponding to the response code:
|
|
124
|
+
#
|
|
125
|
+
# For 2xx status codes: return the response itself
|
|
126
|
+
#
|
|
127
|
+
# For 30x status codes:
|
|
128
|
+
# 301, 302, 307: redirect GET / HEAD if there is a Location header
|
|
129
|
+
# 303: redirect, changing method to GET, if there is a Location header
|
|
130
|
+
#
|
|
131
|
+
# For all other responses, raise a response exception
|
|
132
|
+
#
|
|
133
|
+
def return!(&block)
|
|
134
|
+
case code
|
|
135
|
+
when 200..299
|
|
136
|
+
self
|
|
137
|
+
when 301, 302, 307
|
|
138
|
+
case request.method
|
|
139
|
+
when 'get', 'head'
|
|
140
|
+
check_max_redirects
|
|
141
|
+
follow_redirection(&block)
|
|
142
|
+
else
|
|
143
|
+
raise exception_with_response
|
|
144
|
+
end
|
|
145
|
+
when 303
|
|
146
|
+
check_max_redirects
|
|
147
|
+
follow_get_redirection(&block)
|
|
148
|
+
else
|
|
149
|
+
raise exception_with_response
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def to_i
|
|
154
|
+
warn('warning: calling Response#to_i is not recommended')
|
|
155
|
+
super
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def description
|
|
159
|
+
"#{code} #{STATUSES[code]} | #{(headers[:content_type] || '').gsub(/;.*$/, '')} #{size} bytes\n"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Follow a redirection response by making a new HTTP request to the
|
|
163
|
+
# redirection target.
|
|
164
|
+
def follow_redirection(&block)
|
|
165
|
+
_follow_redirection(request.args.dup, &block)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Follow a redirection response, but change the HTTP method to GET and drop
|
|
169
|
+
# the payload from the original request.
|
|
170
|
+
def follow_get_redirection(&block)
|
|
171
|
+
new_args = request.args.dup
|
|
172
|
+
new_args[:method] = :get
|
|
173
|
+
new_args.delete(:payload)
|
|
174
|
+
|
|
175
|
+
_follow_redirection(new_args, &block)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Convert headers hash into canonical form.
|
|
179
|
+
#
|
|
180
|
+
# Header names will be converted to lowercase symbols with underscores
|
|
181
|
+
# instead of hyphens.
|
|
182
|
+
#
|
|
183
|
+
# Headers specified multiple times will be joined by comma and space,
|
|
184
|
+
# except for Set-Cookie, which will always be an array.
|
|
185
|
+
#
|
|
186
|
+
# Per RFC 2616, if a server sends multiple headers with the same key, they
|
|
187
|
+
# MUST be able to be joined into a single header by a comma. However,
|
|
188
|
+
# Set-Cookie (RFC 6265) cannot because commas are valid within cookie
|
|
189
|
+
# definitions. The newer RFC 7230 notes (3.2.2) that Set-Cookie should be
|
|
190
|
+
# handled as a special case.
|
|
191
|
+
#
|
|
192
|
+
# http://tools.ietf.org/html/rfc2616#section-4.2
|
|
193
|
+
# http://tools.ietf.org/html/rfc7230#section-3.2.2
|
|
194
|
+
# http://tools.ietf.org/html/rfc6265
|
|
195
|
+
#
|
|
196
|
+
# @param headers [Hash]
|
|
197
|
+
# @return [Hash]
|
|
198
|
+
#
|
|
199
|
+
def self.beautify_headers(headers)
|
|
200
|
+
headers.each_with_object({}) do |(key, value), out|
|
|
201
|
+
key_sym = key.tr('-', '_').downcase.to_sym
|
|
202
|
+
|
|
203
|
+
# Handle Set-Cookie specially since it cannot be joined by comma.
|
|
204
|
+
out[key_sym] =
|
|
205
|
+
if key.downcase == 'set-cookie'
|
|
206
|
+
value
|
|
207
|
+
else
|
|
208
|
+
value.join(', ')
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
# Follow a redirection
|
|
216
|
+
#
|
|
217
|
+
# @param new_args [Hash] Start with this hash of arguments for the
|
|
218
|
+
# redirection request. The hash will be mutated, so be sure to dup any
|
|
219
|
+
# existing hash that should not be modified.
|
|
220
|
+
#
|
|
221
|
+
def _follow_redirection(new_args, &block)
|
|
222
|
+
# parse location header and merge into existing URL
|
|
223
|
+
location = headers[:location]
|
|
224
|
+
|
|
225
|
+
# cannot follow redirection if there is no location header
|
|
226
|
+
raise exception_with_response unless location
|
|
227
|
+
|
|
228
|
+
target = URI.parse(location)
|
|
229
|
+
target = request.uri.merge(target) unless target.absolute?
|
|
230
|
+
|
|
231
|
+
# Never downgrade an encrypted request to plaintext.
|
|
232
|
+
raise exception_with_response if request.uri.scheme == 'https' && target.scheme == 'http'
|
|
233
|
+
|
|
234
|
+
# Reject redirect targets that are not http/https. Without this
|
|
235
|
+
# check, contractful follows file://, data:, gopher:// and other
|
|
236
|
+
# exotic schemes; Net::HTTP then fails with a confusing error
|
|
237
|
+
# after the request itself has been issued. More importantly, the
|
|
238
|
+
# SSRF surface is wider than intended: a 302 to http://localhost:6379
|
|
239
|
+
# or http://169.254.169.254 (cloud metadata) is followed as-is.
|
|
240
|
+
# Sensitive headers (Authorization, Cookie) are stripped cross-origin
|
|
241
|
+
# below, but the request body still goes out to an attacker-chosen
|
|
242
|
+
# target. Restrict to http/https so at least the protocol surface
|
|
243
|
+
# stays bounded.
|
|
244
|
+
raise exception_with_response unless %w[http https].include?(target.scheme.to_s.downcase)
|
|
245
|
+
|
|
246
|
+
new_args[:url] = target.to_s
|
|
247
|
+
|
|
248
|
+
if same_origin?(request.uri, target)
|
|
249
|
+
new_args[:password] = request.password
|
|
250
|
+
new_args[:user] = request.user
|
|
251
|
+
new_args[:headers] = request.headers
|
|
252
|
+
else
|
|
253
|
+
# A redirect to another origin must not receive the caller's
|
|
254
|
+
# credentials (basic auth or an explicit Authorization/Cookie header).
|
|
255
|
+
new_args.delete(:user)
|
|
256
|
+
new_args.delete(:password)
|
|
257
|
+
new_args[:headers] = without_sensitive_headers(request.headers)
|
|
258
|
+
end
|
|
259
|
+
new_args[:max_redirects] = request.max_redirects - 1
|
|
260
|
+
|
|
261
|
+
# pass through our new cookie jar
|
|
262
|
+
new_args[:cookies] = cookie_jar
|
|
263
|
+
|
|
264
|
+
# prepare new request
|
|
265
|
+
new_req = Request.new(new_args)
|
|
266
|
+
|
|
267
|
+
# append self to redirection history
|
|
268
|
+
new_req.redirection_history = history + [self]
|
|
269
|
+
|
|
270
|
+
# execute redirected request
|
|
271
|
+
new_req.execute(&block)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def same_origin?(from, to)
|
|
275
|
+
from.scheme == to.scheme && from.host == to.host && from.port == to.port
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def without_sensitive_headers(headers)
|
|
279
|
+
headers.reject do |key, _value|
|
|
280
|
+
SENSITIVE_REDIRECT_HEADERS.include?(key.to_s.downcase)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def check_max_redirects
|
|
285
|
+
return unless request.max_redirects <= 0
|
|
286
|
+
|
|
287
|
+
raise exception_with_response
|
|
288
|
+
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def exception_with_response
|
|
292
|
+
begin
|
|
293
|
+
klass = Exceptions::EXCEPTIONS_MAP.fetch(code)
|
|
294
|
+
rescue KeyError
|
|
295
|
+
raise RequestFailed.new(self, code)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
raise klass.new(self, code)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Contractful
|
|
4
|
+
# Raised instead of sending a request when the circuit for the target
|
|
5
|
+
# host is open. Not retryable within the same call — the breaker exists
|
|
6
|
+
# precisely to stop hammering a host that is failing; back off at a
|
|
7
|
+
# higher level and try again after the cooldown.
|
|
8
|
+
# (Contractful::Exception is itself a RuntimeError; the cop's literal
|
|
9
|
+
# lint on '< Exception' does not see through the constant.)
|
|
10
|
+
class CircuitOpenError < Exception # rubocop:disable Lint/InheritException
|
|
11
|
+
# The base Contractful::Exception#initialize takes a response object,
|
|
12
|
+
# not a message — take the message explicitly.
|
|
13
|
+
def initialize(message = nil)
|
|
14
|
+
super()
|
|
15
|
+
@message = message
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def retryable?
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A host-level circuit breaker (closed → open → half-open → closed).
|
|
24
|
+
#
|
|
25
|
+
# Counted as failures: network errors from {Request::RETRIABLE_EXCEPTIONS}
|
|
26
|
+
# and responses carrying a retryable status (408/425/429/5xx — see
|
|
27
|
+
# {Exceptions::RETRIABLE_STATUSES}). Everything else, including 4xx
|
|
28
|
+
# client errors, is a success: a circuit must not open because a caller
|
|
29
|
+
# keeps asking for a missing resource.
|
|
30
|
+
#
|
|
31
|
+
# Breakers are process-wide, keyed by scheme/host/port and guarded by a
|
|
32
|
+
# mutex; while half-open, requests are allowed through and each outcome
|
|
33
|
+
# decides the next state. State transitions post a `circuit` event to
|
|
34
|
+
# {Notifications} with the key and both states.
|
|
35
|
+
#
|
|
36
|
+
# Opt in per request with `breaker: true` (defaults) or
|
|
37
|
+
# `breaker: { threshold: 5, cooldown: 30 }`:
|
|
38
|
+
#
|
|
39
|
+
# Contractful.get('https://api.example.com/x', breaker: true)
|
|
40
|
+
#
|
|
41
|
+
# After `threshold` consecutive failures the circuit opens and every
|
|
42
|
+
# request to that host fails fast with {CircuitOpenError} for `cooldown`
|
|
43
|
+
# seconds; the next request after the cooldown probes the host
|
|
44
|
+
# (half-open) and its outcome closes or re-opens the circuit. Failures
|
|
45
|
+
# are counted per logical request — the retry layer inside a request is
|
|
46
|
+
# already absorbing transient errors, so only a request that ultimately
|
|
47
|
+
# failed counts.
|
|
48
|
+
class CircuitBreaker
|
|
49
|
+
DEFAULT_THRESHOLD = 5
|
|
50
|
+
DEFAULT_COOLDOWN = 30.0
|
|
51
|
+
|
|
52
|
+
CLOCK = Process::CLOCK_MONOTONIC
|
|
53
|
+
|
|
54
|
+
attr_reader :key, :threshold, :cooldown
|
|
55
|
+
|
|
56
|
+
def initialize(key, threshold: DEFAULT_THRESHOLD, cooldown: DEFAULT_COOLDOWN)
|
|
57
|
+
raise ArgumentError, 'threshold must be a positive Integer' unless threshold.is_a?(Integer) && threshold.positive?
|
|
58
|
+
raise ArgumentError, 'cooldown must be a positive Numeric' unless cooldown.is_a?(Numeric) && cooldown.positive?
|
|
59
|
+
|
|
60
|
+
@key = key
|
|
61
|
+
@threshold = threshold
|
|
62
|
+
@cooldown = cooldown
|
|
63
|
+
@state = :closed
|
|
64
|
+
@failures = 0
|
|
65
|
+
@opened_at = nil
|
|
66
|
+
@mutex = Mutex.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Current state, lazily transitioning open → half-open once the
|
|
70
|
+
# cooldown has elapsed. Notifications are posted after releasing the
|
|
71
|
+
# lock so that subscribers may safely inspect the breaker.
|
|
72
|
+
def state
|
|
73
|
+
from, current = @mutex.synchronize { advance_open_state }
|
|
74
|
+
post_transition(from, :half_open) if from
|
|
75
|
+
current
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Raise {CircuitOpenError} if the circuit is open; otherwise allow the
|
|
79
|
+
# request (a half-open circuit lets the probe through).
|
|
80
|
+
def check!
|
|
81
|
+
from = nil
|
|
82
|
+
error = @mutex.synchronize do
|
|
83
|
+
from, = advance_open_state
|
|
84
|
+
@state == :open ? open_error : nil
|
|
85
|
+
end
|
|
86
|
+
post_transition(from, :half_open) if from
|
|
87
|
+
raise error if error
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_success
|
|
91
|
+
from = nil
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
from = @state unless @state == :closed
|
|
94
|
+
@state = :closed
|
|
95
|
+
@failures = 0
|
|
96
|
+
@opened_at = nil
|
|
97
|
+
end
|
|
98
|
+
post_transition(from, :closed) if from
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def record_failure
|
|
103
|
+
from = @mutex.synchronize { count_failure! }
|
|
104
|
+
post_transition(from, :open) if from
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Judge a transmitted response: retryable statuses are failures,
|
|
109
|
+
# everything else (4xx included) is a success. An unknown result (for
|
|
110
|
+
# example the raw `block_response` path, which has no Response object)
|
|
111
|
+
# is not evidence of success and leaves the breaker unchanged.
|
|
112
|
+
def record_response(response)
|
|
113
|
+
return unless response.respond_to?(:code)
|
|
114
|
+
|
|
115
|
+
code = response.code.to_i
|
|
116
|
+
return if code.zero?
|
|
117
|
+
|
|
118
|
+
if Exceptions.retryable?(code)
|
|
119
|
+
record_failure
|
|
120
|
+
else
|
|
121
|
+
record_success
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Process-wide registry; breaker_for lazily creates one breaker per
|
|
126
|
+
# scheme/host/port key.
|
|
127
|
+
#
|
|
128
|
+
# The previous `||=` form was not atomic: two threads racing on
|
|
129
|
+
# first use both observed @registry as nil and each built a fresh
|
|
130
|
+
# Registry — one was discarded, losing track of the breakers it
|
|
131
|
+
# had created (so the breaker state diverged per-thread for the
|
|
132
|
+
# lifetime of the process until the next reset). Use a Mutex
|
|
133
|
+
# constant to serialize the lazy init. A constant is used because
|
|
134
|
+
# methods defined inside `class << self` execute with self = the
|
|
135
|
+
# class, but instance variables assigned inside the `class << self`
|
|
136
|
+
# block live on the singleton class — the two scopes do not match.
|
|
137
|
+
REGISTRY_MUTEX = Mutex.new
|
|
138
|
+
|
|
139
|
+
class << self
|
|
140
|
+
def registry
|
|
141
|
+
REGISTRY_MUTEX.synchronize { @registry ||= Registry.new }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reset_registry
|
|
145
|
+
REGISTRY_MUTEX.synchronize { @registry = nil }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Lazily-materializing breaker store. The first configuration for an
|
|
150
|
+
# origin wins: later calls for the same scheme/host/port reuse the
|
|
151
|
+
# existing breaker and ignore their threshold/cooldown options.
|
|
152
|
+
class Registry
|
|
153
|
+
def initialize
|
|
154
|
+
@breakers = {}
|
|
155
|
+
@mutex = Mutex.new
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def breaker_for(uri, threshold: DEFAULT_THRESHOLD, cooldown: DEFAULT_COOLDOWN)
|
|
159
|
+
key = "#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@breakers[key] ||= CircuitBreaker.new(key, threshold: threshold, cooldown: cooldown)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def clear!
|
|
166
|
+
@mutex.synchronize { @breakers.clear }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def size
|
|
170
|
+
@mutex.synchronize { @breakers.size }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
# Move an expired open circuit to half-open. Must be called with the
|
|
177
|
+
# mutex held; returns the previous state (nil when no transition) and
|
|
178
|
+
# the resulting state.
|
|
179
|
+
def advance_open_state
|
|
180
|
+
return [nil, @state] unless @state == :open && cooldown_elapsed?
|
|
181
|
+
|
|
182
|
+
from = @state
|
|
183
|
+
@state = :half_open
|
|
184
|
+
@opened_at = nil
|
|
185
|
+
[from, @state]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Count a failure and open the circuit at the threshold. Must be called
|
|
189
|
+
# with the mutex held; returns the previous state when it opened.
|
|
190
|
+
def count_failure!
|
|
191
|
+
@failures += 1
|
|
192
|
+
return nil unless @state == :half_open || @failures >= @threshold
|
|
193
|
+
|
|
194
|
+
from = @state
|
|
195
|
+
@state = :open
|
|
196
|
+
@opened_at = Process.clock_gettime(CLOCK)
|
|
197
|
+
@failures = 0
|
|
198
|
+
from
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def open_error
|
|
202
|
+
CircuitOpenError.new(
|
|
203
|
+
"circuit open for #{@key} " \
|
|
204
|
+
"(retry after #{remaining_cooldown.round(1)}s)"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def cooldown_elapsed?
|
|
209
|
+
@opened_at && (Process.clock_gettime(CLOCK) - @opened_at) >= @cooldown
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def remaining_cooldown
|
|
213
|
+
left = @cooldown - (Process.clock_gettime(CLOCK) - @opened_at)
|
|
214
|
+
left.positive? ? left : 0.0
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def post_transition(from, to)
|
|
218
|
+
Notifications.post('circuit', key: @key, from: from, state: to)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Root namespace; this file adds the persistent-connection pool.
|
|
4
|
+
module Contractful
|
|
5
|
+
# A small thread-safe pool for reusing started Net::HTTP connections
|
|
6
|
+
# (HTTP keep-alive / persistent connections) across requests.
|
|
7
|
+
#
|
|
8
|
+
# The pool is deliberately simple: there is no limit on the number of
|
|
9
|
+
# simultaneously checked-out connections (a concurrent request that cannot
|
|
10
|
+
# reuse an idle connection simply opens a new one, exactly like a
|
|
11
|
+
# non-persistent request would), but at most `max_idle` connections are
|
|
12
|
+
# retained per pool key once they are returned. Connections are checked out
|
|
13
|
+
# exclusively, so a connection is never shared by two threads at once.
|
|
14
|
+
#
|
|
15
|
+
# Requests opt in per request with `persistent: true`; the pool itself is
|
|
16
|
+
# process-wide and available as `Contractful.connection_pool` (for example
|
|
17
|
+
# call `Contractful.connection_pool.clear!` before forking, or after a
|
|
18
|
+
# network topology change).
|
|
19
|
+
class ConnectionPool
|
|
20
|
+
attr_reader :max_idle
|
|
21
|
+
|
|
22
|
+
def initialize(max_idle: 8)
|
|
23
|
+
unless max_idle.is_a?(Integer) && max_idle >= 0
|
|
24
|
+
raise ArgumentError.new(
|
|
25
|
+
":max_idle must be a non-negative Integer, got #{max_idle.inspect}"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@max_idle = max_idle
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@idle = Hash.new { |hash, key| hash[key] = [] }
|
|
32
|
+
@checked_out = Hash.new(0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check a connection out for `key`. The `builder` block is invoked (outside
|
|
36
|
+
# the pool lock) to create a new connection when no idle one is available.
|
|
37
|
+
#
|
|
38
|
+
# @return the checked-out connection
|
|
39
|
+
#
|
|
40
|
+
def checkout(key, &builder)
|
|
41
|
+
raise ArgumentError.new('builder block is required') unless block_given?
|
|
42
|
+
|
|
43
|
+
conn = nil
|
|
44
|
+
@mutex.synchronize { conn = @idle[key].shift }
|
|
45
|
+
|
|
46
|
+
if conn && !revive(conn)
|
|
47
|
+
# Idle connection was closed or otherwise unusable; close it
|
|
48
|
+
# before discarding the reference so any half-opened socket is
|
|
49
|
+
# released immediately rather than waiting for GC to finalize
|
|
50
|
+
# the Net::HTTP object.
|
|
51
|
+
shutdown(conn)
|
|
52
|
+
conn = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
conn ||= builder.call
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize { @checked_out[key] += 1 }
|
|
58
|
+
conn
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Return a healthy connection to the pool for future reuse. If the pool is
|
|
62
|
+
# already retaining `max_idle` connections for this key, the connection is
|
|
63
|
+
# closed instead.
|
|
64
|
+
#
|
|
65
|
+
# Guard the counter decrement against drift: invalidate() decrements
|
|
66
|
+
# conditionally (only if positive), but checkin() previously decremented
|
|
67
|
+
# unconditionally, which would push the count below zero on a
|
|
68
|
+
# double-checkin or checkin-without-checkout. Both methods now share the
|
|
69
|
+
# same guard.
|
|
70
|
+
def checkin(key, conn)
|
|
71
|
+
keep = checkin_under_lock(key, conn)
|
|
72
|
+
shutdown(conn) unless keep
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @api private
|
|
77
|
+
def checkin_under_lock(key, conn)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
return nil unless @checked_out[key].positive?
|
|
80
|
+
|
|
81
|
+
@checked_out[key] -= 1
|
|
82
|
+
if @idle[key].size < @max_idle
|
|
83
|
+
@idle[key].push(conn)
|
|
84
|
+
true
|
|
85
|
+
else
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Discard a connection whose state may no longer be trusted (for example
|
|
92
|
+
# after a network error). The connection is closed and never reused.
|
|
93
|
+
def invalidate(key, conn)
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
@checked_out[key] -= 1 if @checked_out[key].positive?
|
|
96
|
+
end
|
|
97
|
+
shutdown(conn)
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Close all idle connections and forget them. Connections that are
|
|
102
|
+
# currently checked out cannot be closed here; they will be discarded or
|
|
103
|
+
# reused normally when returned.
|
|
104
|
+
def clear!
|
|
105
|
+
conns = []
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@idle.each_value do |idle_conns|
|
|
108
|
+
conns.concat(idle_conns)
|
|
109
|
+
idle_conns.clear
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
conns.each { |conn| shutdown(conn) }
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Hash] counts of idle and checked-out connections
|
|
117
|
+
def stats
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
{
|
|
120
|
+
idle: @idle.values.reduce(0) { |sum, idle_conns| sum + idle_conns.size },
|
|
121
|
+
checked_out: @checked_out.values.reduce(0, :+)
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Prepare an idle connection for reuse. Returns true if the connection is
|
|
129
|
+
# started (possibly after re-establishing it), false if it is unusable and
|
|
130
|
+
# a new connection should be built instead.
|
|
131
|
+
def revive(conn)
|
|
132
|
+
return true if conn.started?
|
|
133
|
+
|
|
134
|
+
conn.start
|
|
135
|
+
true
|
|
136
|
+
rescue StandardError
|
|
137
|
+
false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def shutdown(conn)
|
|
141
|
+
conn.finish if conn.started?
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The process-wide connection pool used by requests created with
|
|
148
|
+
# `persistent: true`.
|
|
149
|
+
#
|
|
150
|
+
# Initialized eagerly at module load. The previous `||=` form was not
|
|
151
|
+
# atomic: two threads racing on first use both observed @connection_pool
|
|
152
|
+
# as nil and each built a fresh pool — one was discarded, and its idle
|
|
153
|
+
# connections' sockets leaked until GC finalized the Net::HTTP objects
|
|
154
|
+
# (Net::HTTP has no finalizer that closes them). The same pattern exists
|
|
155
|
+
# in Contractful::CircuitBreaker.registry and Contractful::Pacer.registry
|
|
156
|
+
# — those modules share the same fix shape.
|
|
157
|
+
@connection_pool = ConnectionPool.new
|
|
158
|
+
|
|
159
|
+
def self.connection_pool
|
|
160
|
+
@connection_pool
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.connection_pool=(pool)
|
|
164
|
+
@connection_pool = pool
|
|
165
|
+
end
|
|
166
|
+
end
|