client-api-builder 0.8.0 → 0.10.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.
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+
6
+ module ClientApiBuilder
7
+ module HTTP2
8
+ # One HTTP/2 connection carrying concurrent requests as streams. A reader thread feeds the
9
+ # socket's bytes to the http-2 client, whose callbacks hand each stream's events to its
10
+ # Exchange. Every call into the http-2 client, and so every socket write, happens under one
11
+ # mutex; the reader blocks in readpartial outside it. (OpenSSL never reads and writes at
12
+ # once: Ruby holds the GVL while calling SSL_read and SSL_write.)
13
+ #
14
+ # A connection stops taking new streams once either side sends GOAWAY or it fails, and
15
+ # closes its socket when its last stream finishes.
16
+ class Connection
17
+ READ_SIZE = 16_384
18
+
19
+ # Returns nil, after closing the socket, when the server chose HTTP/1.1
20
+ def self.open(host, port, authority, connection_options)
21
+ socket = TLSSocket.open(host, port, connection_options)
22
+ return new(socket, authority, connection_options[:read_timeout]) if socket.alpn_protocol == 'h2'
23
+
24
+ socket.close
25
+ nil
26
+ end
27
+
28
+ def initialize(socket, authority, read_timeout)
29
+ @socket = socket
30
+ @authority = authority
31
+ @read_timeout = read_timeout
32
+ @mutex = Mutex.new
33
+ @stream_closed = ConditionVariable.new
34
+ @exchanges = {}
35
+ @error = nil
36
+ @client = build_client
37
+ @mutex.synchronize { @client.send_connection_preface }
38
+ @reader = Thread.new { read_loop }
39
+ end
40
+
41
+ # Sends a Net::HTTP request and, like Net::HTTP#request, yields the response before its
42
+ # body is read, then reads whatever the block left and returns the response
43
+ def request(net_request)
44
+ exchange = start(net_request)
45
+ response = ResponseBuilder.build(exchange.response_headers, exchange, net_request)
46
+ yield response if block_given?
47
+ response.read_body
48
+ response
49
+ ensure
50
+ cancel(exchange) if exchange
51
+ end
52
+
53
+ # True while the connection can take new streams
54
+ def open?
55
+ @mutex.synchronize { @error.nil? && !@client.closed? }
56
+ end
57
+
58
+ # Says goodbye to the server and closes the socket; streams still in flight fail
59
+ def close
60
+ @mutex.synchronize do
61
+ send_goaway unless @error || @client.closed?
62
+ abort(ConnectionLost.new("HTTP/2 connection to #{@authority} was closed"))
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def build_client
69
+ client = ::HTTP2::Client.new
70
+ client.on(:frame) { |bytes| @socket.write(bytes) }
71
+ client.on(:goaway) { |last_stream_id, _error, _payload| refuse_streams_after(last_stream_id) }
72
+ client
73
+ end
74
+
75
+ def start(net_request)
76
+ headers = RequestHeaders.build(net_request, @authority)
77
+ body = net_request.body
78
+ @mutex.synchronize do
79
+ stream = open_stream
80
+ exchange = @exchanges[stream.id] = Exchange.new(stream, @read_timeout)
81
+ stream.on(:close) { stream_closed(stream.id) }
82
+ stream.headers(headers, end_stream: body.nil?)
83
+ stream.data(body) if body
84
+ exchange
85
+ end
86
+ end
87
+
88
+ # Waits, up to read_timeout, while the server's limit on concurrent streams is reached
89
+ def open_stream
90
+ deadline = @read_timeout && (now + @read_timeout)
91
+ loop do
92
+ raise_if_failed!
93
+ return @client.new_stream
94
+ rescue ::HTTP2::Error::StreamLimitExceeded
95
+ wait_for_stream_slot(deadline)
96
+ end
97
+ rescue ::HTTP2::Error::ConnectionClosed
98
+ raise StreamRefused, "HTTP/2 connection to #{@authority} is no longer taking new streams"
99
+ end
100
+
101
+ def wait_for_stream_slot(deadline)
102
+ remaining = deadline && (deadline - now)
103
+ if remaining && !remaining.positive?
104
+ raise Net::ReadTimeout, "no HTTP/2 stream to #{@authority} became available within #{@read_timeout}s"
105
+ end
106
+
107
+ @stream_closed.wait(@mutex, remaining)
108
+ end
109
+
110
+ def raise_if_failed!
111
+ raise @error.exception(@error.message) if @error
112
+ end
113
+
114
+ # Resets a stream the caller gave up on (it raised or timed out) so the server stops sending.
115
+ # Streams that finished, were refused or failed with the connection are no longer tracked.
116
+ def cancel(exchange)
117
+ @mutex.synchronize do
118
+ exchange.stream.cancel if @exchanges.key?(exchange.stream.id)
119
+ end
120
+ end
121
+
122
+ # The peer may already be gone; the socket is closed next either way
123
+ def send_goaway
124
+ @client.goaway
125
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
126
+ ::ClientApiBuilder.logger&.warn("HTTP/2 connection to #{@authority} failed to send GOAWAY: #{e.message}")
127
+ end
128
+
129
+ # Called from the reader thread, holding the mutex
130
+ def stream_closed(stream_id)
131
+ @exchanges.delete(stream_id)
132
+ @stream_closed.broadcast
133
+ close_if_drained
134
+ end
135
+
136
+ # Once no new streams may open, the socket closes with the last stream, which ends the reader
137
+ def close_if_drained
138
+ @socket.close if @client.closed? && @exchanges.empty?
139
+ end
140
+
141
+ # Streams the server will never process after GOAWAY are safe to retry elsewhere
142
+ def refuse_streams_after(last_stream_id)
143
+ @exchanges.select { |id, _| id > last_stream_id }.each do |id, exchange|
144
+ @exchanges.delete(id)
145
+ exchange.fail(StreamRefused.new("HTTP/2 server at #{@authority} went away before stream #{id} was processed"))
146
+ end
147
+ @stream_closed.broadcast
148
+ close_if_drained
149
+ end
150
+
151
+ def read_loop
152
+ loop do
153
+ data = @socket.readpartial(READ_SIZE)
154
+ @mutex.synchronize { @client << data }
155
+ end
156
+ rescue StandardError => e
157
+ fail_connection(e)
158
+ end
159
+
160
+ def fail_connection(exception)
161
+ @mutex.synchronize { abort(connection_error(exception)) }
162
+ end
163
+
164
+ # Holding the mutex: fails every stream in flight and closes the socket. The first error
165
+ # sticks, so closing the socket (which ends the reader) doesn't replace the real cause.
166
+ def abort(error)
167
+ @error ||= error
168
+ @exchanges.each_value { |exchange| exchange.fail(@error.exception(@error.message)) }
169
+ @exchanges.clear
170
+ @stream_closed.broadcast
171
+ @socket.close
172
+ end
173
+
174
+ def connection_error(exception)
175
+ if exception.is_a?(::HTTP2::Error::Error)
176
+ return ProtocolError.new("HTTP/2 protocol error from #{@authority}: #{exception.message}")
177
+ end
178
+
179
+ ConnectionLost.new("HTTP/2 connection to #{@authority} lost: #{exception.message} (#{exception.class})")
180
+ end
181
+
182
+ def now
183
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ module HTTP2
5
+ # A client class's HTTP/2 connections: one per host, port and connection options, opened on
6
+ # first use and replaced once it stops taking new streams. Origins whose server chose
7
+ # HTTP/1.1 are remembered so they aren't asked again. After a fork the child starts empty;
8
+ # the parent's connections are dropped without being closed, since closing a shared TLS
9
+ # socket would disturb the parent.
10
+ class ConnectionSet
11
+ def initialize
12
+ reset
13
+ end
14
+
15
+ # Returns an open connection to the URI's origin, or nil when the server speaks only HTTP/1.1.
16
+ # A new connection is opened while holding the set's lock, so other origins wait for it.
17
+ def connection_for(uri, connection_options)
18
+ reset if forked?
19
+ key = [uri.hostname, uri.port, connection_options.dup.freeze].freeze
20
+ @mutex.synchronize do
21
+ return nil if @http1_origins.include?(key)
22
+
23
+ connection = @connections[key]
24
+ connection&.open? ? connection : open_connection(key, uri, connection_options)
25
+ end
26
+ end
27
+
28
+ def close
29
+ connections = @mutex.synchronize do
30
+ @http1_origins.clear
31
+ @connections.values.tap { @connections.clear }
32
+ end
33
+ connections.each(&:close)
34
+ end
35
+
36
+ private
37
+
38
+ def reset
39
+ @pid = Process.pid
40
+ @mutex = Mutex.new
41
+ @connections = {}
42
+ @http1_origins = Set.new
43
+ end
44
+
45
+ def forked?
46
+ @pid != Process.pid
47
+ end
48
+
49
+ def open_connection(key, uri, connection_options)
50
+ connection = Connection.open(uri.hostname, uri.port, authority(uri), connection_options)
51
+ connection ? @connections[key] = connection : @http1_origins << key
52
+ connection
53
+ end
54
+
55
+ # host keeps an IPv6 address's brackets; the port is left out when it's the default
56
+ def authority(uri)
57
+ uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module ClientApiBuilder
6
+ module HTTP2
7
+ # One request's stream, seen from the thread waiting on it. The connection's reader thread
8
+ # pushes the stream's events (headers, data, close) or a connection failure onto a queue;
9
+ # the waiting thread pops them, giving up after read_timeout seconds without one.
10
+ #
11
+ # Received data is acknowledged to the server as it arrives, so a consumer that reads
12
+ # slower than the server sends buffers the difference in memory.
13
+ class Exchange
14
+ attr_reader :stream
15
+
16
+ def initialize(stream, read_timeout)
17
+ @stream = stream
18
+ @read_timeout = read_timeout
19
+ @events = Thread::Queue.new
20
+ @finished = false
21
+ stream.on(:headers) { |headers| @events << [:headers, headers] }
22
+ stream.on(:data) { |chunk| @events << [:data, chunk] }
23
+ stream.on(:close) { |error| @events << [:close, error] }
24
+ end
25
+
26
+ # Called by the connection when the stream will never finish
27
+ def fail(error)
28
+ @events << [:error, error]
29
+ end
30
+
31
+ # Waits for the final response headers, skipping informational (1xx) ones
32
+ def response_headers
33
+ loop do
34
+ type, value = next_event
35
+ raise stream_closed_error(value || :no_error) if type == :close
36
+ return value unless informational?(value)
37
+ end
38
+ end
39
+
40
+ # Yields each chunk of the response body until the stream ends. Trailers are ignored.
41
+ def each_chunk
42
+ until @finished
43
+ type, value = next_event
44
+ yield value if type == :data
45
+ finish(value) if type == :close
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def next_event
52
+ type, value = @events.pop(timeout: @read_timeout)
53
+ raise Net::ReadTimeout, "no response on HTTP/2 stream #{stream.id} within #{@read_timeout}s" if type.nil?
54
+ raise value if type == :error
55
+
56
+ [type, value]
57
+ end
58
+
59
+ def finish(error)
60
+ raise stream_closed_error(error) unless error.nil? || error == :no_error
61
+
62
+ @finished = true
63
+ end
64
+
65
+ def informational?(headers)
66
+ headers.any? { |name, value| name == ':status' && value.start_with?('1') }
67
+ end
68
+
69
+ def stream_closed_error(error)
70
+ return StreamRefused.new("server refused HTTP/2 stream #{stream.id}") if error == :refused_stream
71
+
72
+ StreamError.new("HTTP/2 stream #{stream.id} closed before the response finished (#{error})")
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ module HTTP2
5
+ # Turns a Net::HTTP request into an HTTP/2 header list: the pseudo-headers first, then the
6
+ # request's own headers (already lowercase) without the connection-specific ones HTTP/2
7
+ # forbids (RFC 9113 section 8.2.2). Host becomes :authority.
8
+ module RequestHeaders
9
+ CONNECTION_HEADERS = %w[connection host keep-alive proxy-connection transfer-encoding upgrade].freeze
10
+
11
+ module_function
12
+
13
+ def build(net_request, authority)
14
+ headers = pseudo_headers(net_request, authority)
15
+ net_request.each_header do |name, value|
16
+ headers << [name, value] unless connection_header?(name, value)
17
+ end
18
+ body = net_request.body
19
+ headers << ['content-length', body.bytesize.to_s] if body && !net_request.key?('content-length')
20
+ headers
21
+ end
22
+
23
+ def pseudo_headers(net_request, authority)
24
+ [
25
+ [':method', net_request.method],
26
+ [':scheme', 'https'],
27
+ [':authority', net_request['host'] || authority],
28
+ [':path', net_request.path]
29
+ ]
30
+ end
31
+
32
+ # te is allowed only as "trailers"
33
+ def connection_header?(name, value)
34
+ CONNECTION_HEADERS.include?(name) || (name == 'te' && value != 'trailers')
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'zlib'
5
+
6
+ module ClientApiBuilder
7
+ module HTTP2
8
+ # Extended onto a Net::HTTPResponse built by ResponseBuilder: read_body reads the HTTP/2
9
+ # stream instead of a socket, with Net::HTTP's semantics (into a string, a given buffer or a
10
+ # block, once). A body that isn't expected is drained and left nil.
11
+ module ResponseBody
12
+ def http2_body(exchange, inflate:, expected:)
13
+ @http2_exchange = exchange
14
+ @http2_inflate = inflate
15
+ @http2_body_expected = expected
16
+ end
17
+
18
+ def read_body(dest = nil, &block)
19
+ if @read
20
+ raise IOError, "#{self.class}#read_body called twice" if dest || block
21
+
22
+ return @body
23
+ end
24
+ raise ArgumentError, 'both arg and block given for HTTP method' if dest && block
25
+
26
+ @body = @http2_body_expected ? read_http2_body(body_destination(dest, block)) : drain
27
+ @read = true
28
+ @body
29
+ end
30
+
31
+ private
32
+
33
+ def body_destination(dest, block)
34
+ return Net::ReadAdapter.new(block) if block
35
+
36
+ dest || String.new
37
+ end
38
+
39
+ def read_http2_body(dest)
40
+ inflater = Zlib::Inflate.new(32 + Zlib::MAX_WBITS) if @http2_inflate
41
+ @http2_exchange.each_chunk { |chunk| dest << (inflater ? inflater.inflate(chunk) : chunk) }
42
+ dest << inflater.finish if inflater
43
+ dest
44
+ ensure
45
+ inflater&.close
46
+ end
47
+
48
+ def drain
49
+ @http2_exchange.each_chunk { |_chunk| nil }
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module ClientApiBuilder
6
+ module HTTP2
7
+ # Builds the Net::HTTPResponse for an HTTP/2 response, so code written against Net::HTTP
8
+ # (handle_response, UnexpectedResponse#response, response['header']) works unchanged.
9
+ # HTTP/2 has no reason phrase, so message is empty. Like Net::HTTP, a gzip or deflate body
10
+ # is inflated when Net::HTTP chose accept-encoding itself (request.decode_content); its
11
+ # content-encoding and content-length headers are then removed.
12
+ module ResponseBuilder
13
+ INFLATABLE_ENCODINGS = %w[gzip deflate].freeze
14
+
15
+ module_function
16
+
17
+ def build(headers, exchange, net_request)
18
+ status = headers.assoc(':status').last
19
+ response = response_class(status).new('2.0', status, '')
20
+ headers.each { |name, value| response.add_field(name, value) unless name.start_with?(':') }
21
+ inflate = net_request.decode_content && inflatable?(response)
22
+ response.delete('content-encoding') if inflate
23
+ response.delete('content-length') if inflate
24
+ response.extend(ResponseBody)
25
+ response.http2_body(exchange, inflate: inflate, expected: body_expected?(response, net_request))
26
+ response
27
+ end
28
+
29
+ def response_class(status)
30
+ Net::HTTPResponse::CODE_TO_OBJ[status] ||
31
+ Net::HTTPResponse::CODE_CLASS_TO_OBJ[status[0]] ||
32
+ Net::HTTPUnknownResponse
33
+ end
34
+
35
+ def inflatable?(response)
36
+ INFLATABLE_ENCODINGS.include?(response['content-encoding']&.downcase)
37
+ end
38
+
39
+ # A HEAD request or a 204/304 response has no body; Net::HTTP leaves it nil
40
+ def body_expected?(response, net_request)
41
+ net_request.response_body_permitted? && response.class.body_permitted?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'resolv'
6
+ require 'socket'
7
+
8
+ module ClientApiBuilder
9
+ module HTTP2
10
+ # Opens a TLS connection that offers h2 and http/1.1 through ALPN, applying Net::HTTP's
11
+ # SSL connection options and certificate checks. The caller reads alpn_protocol to see
12
+ # which one the server chose.
13
+ module TLSSocket
14
+ ALPN_PROTOCOLS = %w[h2 http/1.1].freeze
15
+
16
+ # Net::HTTP connection option => SSLContext attribute
17
+ SSL_OPTIONS = %i[
18
+ ca_file ca_path cert cert_store ciphers extra_chain_cert key min_version max_version
19
+ ssl_version verify_callback verify_depth verify_hostname verify_mode
20
+ ].to_h { |name| [name, name] }.merge(ssl_timeout: :timeout).freeze
21
+
22
+ WAIT_EVENTS = { wait_readable: IO::READABLE, wait_writable: IO::WRITABLE }.freeze
23
+
24
+ module_function
25
+
26
+ def open(host, port, connection_options)
27
+ context = ssl_context(connection_options)
28
+ tcp = Socket.tcp(host, port, connect_timeout: connection_options[:open_timeout])
29
+ begin
30
+ ssl = start_tls(tcp, host, context, connection_options[:open_timeout])
31
+ ssl.post_connection_check(host) if verify_hostname?(context)
32
+ ssl
33
+ rescue StandardError
34
+ tcp.close
35
+ raise
36
+ end
37
+ end
38
+
39
+ def ssl_context(connection_options)
40
+ params = SSL_OPTIONS.filter_map do |option, attribute|
41
+ [attribute, connection_options[option]] unless connection_options[option].nil?
42
+ end
43
+ OpenSSL::SSL::SSLContext.new.tap do |context|
44
+ context.set_params(params.to_h)
45
+ context.alpn_protocols = ALPN_PROTOCOLS
46
+ end
47
+ end
48
+
49
+ def start_tls(tcp, host, context, timeout)
50
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
51
+ ssl.sync_close = true
52
+ ssl.hostname = host unless ip_address?(host)
53
+ handshake(ssl, timeout && (now + timeout))
54
+ ssl
55
+ end
56
+
57
+ def handshake(ssl, deadline)
58
+ while (state = ssl.connect_nonblock(exception: false)).is_a?(Symbol)
59
+ remaining = deadline && (deadline - now)
60
+ next if ssl.to_io.wait(WAIT_EVENTS.fetch(state), remaining)
61
+
62
+ raise Net::OpenTimeout, 'TLS handshake timed out'
63
+ end
64
+ end
65
+
66
+ # Same rule as Net::HTTP: the certificate must match the host unless verification is off
67
+ def verify_hostname?(context)
68
+ context.verify_mode != OpenSSL::SSL::VERIFY_NONE && context.verify_hostname != false
69
+ end
70
+
71
+ def ip_address?(host)
72
+ host.match?(Resolv::IPv4::Regex) || host.match?(Resolv::IPv6::Regex)
73
+ end
74
+
75
+ def now
76
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Purpose: opt-in HTTP/2 for https requests, using the http-2 gem (add gem 'http-2' to your
4
+ # Gemfile; it isn't a runtime dependency). Include after ClientApiBuilder::Router, and after
5
+ # ClientApiBuilder::ConnectionPools when using both:
6
+ #
7
+ # class MyClient
8
+ # include ClientApiBuilder::Router
9
+ # include ClientApiBuilder::HTTP2
10
+ #
11
+ # base_url 'https://api.example.com'
12
+ # end
13
+ #
14
+ # Every instance of the class shares one connection per origin, carrying concurrent requests
15
+ # as streams. TLS negotiates the protocol (ALPN): when the server picks HTTP/1.1, the origin is
16
+ # remembered and its requests go through Net::HTTP (or the connection pools) instead. http
17
+ # URLs always use HTTP/1.1. Responses are Net::HTTPResponse objects with http_version '2.0'.
18
+ module ClientApiBuilder
19
+ module HTTP2
20
+ autoload :Connection, 'client_api_builder/http2/connection'
21
+ autoload :ConnectionSet, 'client_api_builder/http2/connection_set'
22
+ autoload :Exchange, 'client_api_builder/http2/exchange'
23
+ autoload :RequestHeaders, 'client_api_builder/http2/request_headers'
24
+ autoload :ResponseBody, 'client_api_builder/http2/response_body'
25
+ autoload :ResponseBuilder, 'client_api_builder/http2/response_builder'
26
+ autoload :TLSSocket, 'client_api_builder/http2/tls_socket'
27
+
28
+ # The connection dropped (or was closed) before the response finished
29
+ class ConnectionLost < ::ClientApiBuilder::RetryableError; end
30
+
31
+ # The server refused the stream, or announced it would stop (GOAWAY) before processing it
32
+ class StreamRefused < ::ClientApiBuilder::RetryableError; end
33
+
34
+ # The server reset the stream with an error code
35
+ class StreamError < ::ClientApiBuilder::Error; end
36
+
37
+ # The server broke the HTTP/2 protocol; the connection is closed
38
+ class ProtocolError < ::ClientApiBuilder::Error; end
39
+
40
+ def self.included(base)
41
+ unless base.include?(::ClientApiBuilder::Router)
42
+ raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::HTTP2'
43
+ end
44
+
45
+ require_http2_gem
46
+ base.extend ClassMethods
47
+ base.redefine_class_method(:http2_connections, ConnectionSet.new)
48
+ end
49
+
50
+ def self.require_http2_gem
51
+ require 'http/2'
52
+ rescue LoadError
53
+ raise LoadError, "ClientApiBuilder::HTTP2 needs the http-2 gem; add gem 'http-2' to your Gemfile"
54
+ end
55
+
56
+ module ClassMethods
57
+ # Closes this class's HTTP/2 connections, and its connection pools when it has them
58
+ def close_connections
59
+ http2_connections.close
60
+ super if defined?(super)
61
+ end
62
+ end
63
+
64
+ def with_http_connection(uri, connection_options, &)
65
+ connection = uri.scheme == 'https' && self.class.http2_connections.connection_for(uri, connection_options)
66
+ connection ? yield(connection) : super
67
+ end
68
+ end
69
+ end
@@ -44,6 +44,21 @@ module ClientApiBuilder
44
44
  "Allowed: #{INHERITABLE_SETTINGS.map(&:inspect).join(', ')}"
45
45
  end
46
46
 
47
+ # Gives this section its own connection pools (see ConnectionPools.connection_pool).
48
+ # Including ConnectionPools puts its connection_pool ahead of this one, so the call
49
+ # below configures the pools rather than recursing.
50
+ def self.connection_pool(**settings)
51
+ include ::ClientApiBuilder::ConnectionPools
52
+
53
+ connection_pool(**settings)
54
+ end
55
+
56
+ # Closes the pools of any nested sections that have their own; a section with its own
57
+ # pools uses ConnectionPools.close_connections, which closes those as well
58
+ def self.close_connections
59
+ section_routers.each_value(&:close_connections)
60
+ end
61
+
47
62
  def configured_headers
48
63
  inherits_from_root?(:headers) ? root_router.configured_headers.merge(super) : super
49
64
  end
@@ -64,6 +79,11 @@ module ClientApiBuilder
64
79
  root_router.handle_response(response, options, &)
65
80
  end
66
81
 
82
+ # Uses the root client's connections unless this section calls connection_pool
83
+ def with_http_connection(uri, connection_options, &)
84
+ root_router.with_http_connection(uri, connection_options, &)
85
+ end
86
+
67
87
  private
68
88
 
69
89
  def inherits_from_root?(setting)
@@ -43,13 +43,19 @@ module ClientApiBuilder
43
43
  ssl_options = uri.scheme == 'https' ? DEFAULT_SECURE_OPTIONS.merge(use_ssl: true) : {}
44
44
  merged_options = ssl_options.merge(connection_options)
45
45
 
46
- Net::HTTP.start(uri.hostname, uri.port, merged_options) do |http|
46
+ with_http_connection(uri, merged_options) do |http|
47
47
  http.request(request) do |response|
48
48
  yield response if block_given?
49
49
  end
50
50
  end
51
51
  end
52
52
 
53
+ # Yields a started Net::HTTP session for one request. By default each request opens
54
+ # and closes its own connection; ConnectionPools overrides this to reuse pooled ones.
55
+ def with_http_connection(uri, connection_options, &)
56
+ Net::HTTP.start(uri.hostname, uri.port, connection_options, &)
57
+ end
58
+
53
59
  # validate_response, when given, is called with the response before its body is streamed
54
60
  # and raises to reject it. A rejected body is read into response.body instead of being
55
61
  # streamed, so the error can still show it.