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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +142 -0
  3. data/ARCHITECTURE.md +134 -0
  4. data/AUTHORS +106 -0
  5. data/CODE_OF_CONDUCT.md +128 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1290 -0
  8. data/SECURITY.md +67 -0
  9. data/bin/contractful +128 -0
  10. data/history.md +801 -0
  11. data/lib/contractful/abstract_response.rb +301 -0
  12. data/lib/contractful/circuit_breaker.rb +221 -0
  13. data/lib/contractful/connection_pool.rb +166 -0
  14. data/lib/contractful/contract/auth.rb +257 -0
  15. data/lib/contractful/contract/config.rb +93 -0
  16. data/lib/contractful/contract/definition.rb +295 -0
  17. data/lib/contractful/contract/dsl.rb +501 -0
  18. data/lib/contractful/contract/executor.rb +492 -0
  19. data/lib/contractful/contract/openapi_import.rb +1135 -0
  20. data/lib/contractful/contract/pagination.rb +218 -0
  21. data/lib/contractful/contract/param.rb +505 -0
  22. data/lib/contractful/contract/postman_import/auth.rb +39 -0
  23. data/lib/contractful/contract/postman_import/body.rb +87 -0
  24. data/lib/contractful/contract/postman_import/operation.rb +107 -0
  25. data/lib/contractful/contract/postman_import/scope.rb +37 -0
  26. data/lib/contractful/contract/postman_import/url.rb +80 -0
  27. data/lib/contractful/contract/postman_import.rb +164 -0
  28. data/lib/contractful/contract/response_field.rb +167 -0
  29. data/lib/contractful/contract/sequence.rb +432 -0
  30. data/lib/contractful/contract/swagger2/components.rb +84 -0
  31. data/lib/contractful/contract/swagger2/operations.rb +81 -0
  32. data/lib/contractful/contract/swagger2/parameters.rb +93 -0
  33. data/lib/contractful/contract/swagger2/references.rb +55 -0
  34. data/lib/contractful/contract/swagger2.rb +55 -0
  35. data/lib/contractful/contract.rb +85 -0
  36. data/lib/contractful/exceptions.rb +328 -0
  37. data/lib/contractful/notifications/active_support.rb +40 -0
  38. data/lib/contractful/notifications.rb +138 -0
  39. data/lib/contractful/pacer.rb +161 -0
  40. data/lib/contractful/params_array.rb +85 -0
  41. data/lib/contractful/payload.rb +340 -0
  42. data/lib/contractful/platform.rb +52 -0
  43. data/lib/contractful/rate_limit.rb +133 -0
  44. data/lib/contractful/raw_response.rb +70 -0
  45. data/lib/contractful/request.rb +1483 -0
  46. data/lib/contractful/resource.rb +189 -0
  47. data/lib/contractful/response.rb +109 -0
  48. data/lib/contractful/utils.rb +202 -0
  49. data/lib/contractful/version.rb +11 -0
  50. data/lib/contractful/windows/root_certs.rb +107 -0
  51. data/lib/contractful/windows.rb +9 -0
  52. data/lib/contractful.rb +233 -0
  53. 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