client-api-builder 0.9.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b12b7717adcfba57478c6ac76da774640924fa40dd67b9e570db305989de711b
4
- data.tar.gz: 56ca42f80c9d6b0ae90cdfc131978421ef8cf7f1f80b9e0e96148fb0e23e95eb
3
+ metadata.gz: c5a8b4e5fcad31bffb2d4234fb2d73205ae0e3aed33ba75ec32defa778ed456f
4
+ data.tar.gz: ff349350509add94a7108af7dddf9416b98265752735a510d46d35bc97897849
5
5
  SHA512:
6
- metadata.gz: 1a4f218c711148e498517406e1893aac38cbba09a64062086b0986b4c187b87220ebce08dcba04d33472282f0998c8c299ac66fa89bd4021dfef6a2bda82b1d2
7
- data.tar.gz: 8ba48b3d89d982669e7f7e3a9e2bc5951c96c9c2530b48cd95befd3fff22aed089be12cd04919ccfe43b43801139c65bdbb21364059cde7d55af888e18009d1b
6
+ metadata.gz: cea030b2bb67c167c3dc4f4a798cadd2e4a8c873fe71d19bed944b4263446f0befb3cbdbc6dccbd9741814aeedb52f0b0b7b2aff155f7800c221601d52383fbd
7
+ data.tar.gz: 62d3ccbfc3ad52500a29070c6bad1961ba06539e131fc53e5eb775616c8ca179d91c8bd4f0e3bfa150cb3c40a2f8897f3e8bb957d4ae867400c7ec6a872db42e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/dougyouch/client-api-builder/compare/v0.10.0...v0.11.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **thread_connections:** add per-thread persistent connections ([3ad347d](https://github.com/dougyouch/client-api-builder/commit/3ad347d7a022b60dfb9e0d36de5260dd37f456be))
9
+ * **thread_connections:** add per-thread persistent connections ([4f61cd9](https://github.com/dougyouch/client-api-builder/commit/4f61cd94731ac81c0993bfbee8acf6c9c4328ce2))
10
+
11
+ ## [0.10.0](https://github.com/dougyouch/client-api-builder/compare/v0.9.0...v0.10.0) (2026-10-04)
12
+
13
+
14
+ ### Features
15
+
16
+ * add opt-in HTTP/2 support ([48c6f6e](https://github.com/dougyouch/client-api-builder/commit/48c6f6e37b9c346d2e8b70db30a72661ea18358e))
17
+ * **http2:** add opt-in HTTP/2 support ([10ee136](https://github.com/dougyouch/client-api-builder/commit/10ee13677d1914dd37dd5efb2afee9779e2fdbda))
18
+
19
+
20
+ ### Performance Improvements
21
+
22
+ * **http2:** add HTTP/1.1 vs HTTP/2 benchmark script ([29b9add](https://github.com/dougyouch/client-api-builder/commit/29b9adde11ae6dcf55a5f2eedf0c1826774bb51a))
23
+
3
24
  ## [0.9.0](https://github.com/dougyouch/client-api-builder/compare/v0.8.0...v0.9.0) (2026-10-04)
4
25
 
5
26
 
data/README.md CHANGED
@@ -16,6 +16,8 @@ A Ruby gem for building robust, secure API clients through declarative configura
16
16
  - **Nested Routing** - Organize complex APIs with hierarchical route structures
17
17
  - **Retry Logic** - Configurable automatic retries for transient network failures
18
18
  - **Connection Pooling** - Opt-in persistent connections shared across threads, with no extra gems
19
+ - **Per-Thread Connections** - Opt-in persistent connections dedicated to each thread, with no pool to size
20
+ - **HTTP/2** - Opt-in HTTP/2 over TLS, multiplexing concurrent requests on one connection per host (with the `http-2` gem)
19
21
  - **Streaming Support** - Handle large payloads efficiently with streaming to files or IO
20
22
  - **ActiveSupport Integration** - Optional logging and instrumentation
21
23
  - **Comprehensive Error Handling** - Detailed error information for debugging
@@ -399,6 +401,77 @@ The pools live on the class, like ActiveRecord's, so every instance shares them:
399
401
 
400
402
  Pooling uses only `Net::HTTP` from the standard library.
401
403
 
404
+ ### Per-Thread Connections
405
+
406
+ When a program runs a fixed set of worker threads, each making its own requests, include `ClientApiBuilder::ThreadConnections` (after `Router`, instead of `ConnectionPools`) to give every thread its own persistent connections:
407
+
408
+ ```ruby
409
+ class MyApiClient
410
+ include ClientApiBuilder::Router
411
+ include ClientApiBuilder::ThreadConnections
412
+
413
+ base_url 'https://api.example.com'
414
+
415
+ # Optional; these are the defaults
416
+ connection_per_thread ttl: 30, # seconds before a connection is closed and replaced
417
+ idle_timeout: 2 # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
418
+ end
419
+
420
+ workers = Array.new(20) do
421
+ Thread.new do
422
+ client = MyApiClient.new
423
+ user_ids.each { |id| client.get_user(id: id) } # always over this thread's connection
424
+ end
425
+ end
426
+ ```
427
+
428
+ A thread opens a connection per host (scheme, port and connection options) on its first request and reuses it for every later one. There's no pool size to keep in step with the thread count, and no thread ever waits for another's connection.
429
+
430
+ - Sockets the server has closed, or that sat idle past `idle_timeout`, are reopened automatically. A connection whose request raised is closed rather than reused.
431
+ - A request made while the thread's connection is busy (from inside a streaming block, or from another fiber on the thread) opens a connection of its own; the thread keeps one of the two afterwards.
432
+ - Connections of threads that have finished are closed when a thread makes its first request. With short-lived threads, prefer `ConnectionPools`.
433
+ - Sections and subclasses share the class's connections the same way they share pools, and a section can call `connection_per_thread` in its block to get its own.
434
+ - After a fork, the child process opens its own connections. `MyApiClient.close_connections` closes every thread's idle connections now and in-use ones when their request ends.
435
+
436
+ ### HTTP/2
437
+
438
+ Include `ClientApiBuilder::HTTP2` (after `Router`, and after `ConnectionPools` or `ThreadConnections` if you use either) to send https requests over HTTP/2. It needs the [`http-2`](https://rubygems.org/gems/http-2) gem, which isn't installed with this one:
439
+
440
+ ```ruby
441
+ # Gemfile
442
+ gem 'http-2'
443
+ ```
444
+
445
+ ```ruby
446
+ class MyApiClient
447
+ include ClientApiBuilder::Router
448
+ include ClientApiBuilder::ConnectionPools # optional: used for servers that only speak HTTP/1.1
449
+ include ClientApiBuilder::HTTP2
450
+
451
+ base_url 'https://api.example.com'
452
+ end
453
+ ```
454
+
455
+ Every instance of the class shares one connection per host (and connection options), and concurrent requests from different threads travel over it as separate streams. Responses are ordinary `Net::HTTPResponse` objects with `http_version` `'2.0'`, so `handle_response`, `UnexpectedResponse#response` and streaming routes work unchanged.
456
+
457
+ - The protocol is negotiated during the TLS handshake (ALPN). When a server picks HTTP/1.1, that host is remembered and its requests go through `Net::HTTP`, or the pooled or per-thread connections if the class includes `ConnectionPools` or `ThreadConnections`. `http://` URLs always use HTTP/1.1.
458
+ - The usual connection options apply: `open_timeout`, `read_timeout` (per response header and body chunk), and the SSL options (`verify_mode`, `ca_file`, `cert_store`, `cert`, `key`, ...). Proxies aren't supported.
459
+ - When the server's limit on concurrent streams is reached, a request waits up to `read_timeout` for a stream to free up.
460
+ - Gzip and deflate responses are inflated, as `Net::HTTP` does, unless you set `Accept-Encoding` yourself.
461
+ - A stream the server refused, or never processed before closing the connection (GOAWAY), raises `ClientApiBuilder::HTTP2::StreamRefused`; a dropped connection raises `ClientApiBuilder::HTTP2::ConnectionLost`. Both are retried by `configure_retries`. A stream the server reset raises `ClientApiBuilder::HTTP2::StreamError`.
462
+ - Response data is acknowledged to the server as it arrives, so a streaming consumer slower than the server buffers the difference in memory.
463
+ - Sections use their root client's connections. After a fork, the child opens its own. `MyApiClient.close_connections` closes the HTTP/2 connections (and the pooled or per-thread ones).
464
+
465
+ **When it's faster.** `script/benchmark_http2.rb` compares a new connection per request, `ConnectionPools` and HTTP/2 against a local TLS server. On one machine (Ruby 4.0.7, http-2 1.2.3):
466
+
467
+ | Scenario (1000 requests) | New connection | Pooled | HTTP/2 |
468
+ |---|---|---|---|
469
+ | 1 thread, no server delay | 501 req/s | 4,824 req/s | 2,057 req/s |
470
+ | 50 threads, 20ms delay, one pooled connection per thread | 628 req/s | 1,904 req/s | 1,765 req/s |
471
+ | 50 threads, 20ms delay, default pool of 5 | 640 req/s | 208 req/s | 1,706 req/s |
472
+
473
+ HTTP/2 avoids a TLS handshake per request, like the pools, and isn't limited by a connection count: many threads waiting on a slow API share one connection. Per request, it costs more client CPU than pooled HTTP/1.1 (the protocol layer is pure Ruby), which shows most with large bodies. If you can give each thread its own connection (a pool as large as the thread count, or `ThreadConnections`), HTTP/1.1 is usually as fast or faster.
474
+
402
475
  ### Retry Configuration
403
476
 
404
477
  Configure automatic retries for transient failures:
@@ -719,7 +792,8 @@ end
719
792
  | `configure_retries(max_attempts, sleep = 0.05, backoff: 1, max_sleep: nil, jitter: false)` | Configure retry behavior |
720
793
  | `configure_exponential_retries(attempts:, initial: 0.1, max: 10, multiplier: 2, jitter: false)` | Configure retries with exponential backoff |
721
794
  | `connection_pool(**settings)` | Configure persistent connection pools (requires `include ClientApiBuilder::ConnectionPools`) |
722
- | `close_connections` | Close the class's pooled connections and its sections' (with `ConnectionPools`) |
795
+ | `connection_per_thread(**settings)` | Configure persistent per-thread connections (requires `include ClientApiBuilder::ThreadConnections`) |
796
+ | `close_connections` | Close the class's pooled or per-thread connections and its sections' (with `ConnectionPools` or `ThreadConnections`) |
723
797
  | `section_routers` | Section router classes by name |
724
798
  | `route(name, path, options)` | Define an API endpoint |
725
799
  | `section(name, options, &block)` | Define nested routes; `inherit:` opts into the root client's `:headers`, `:query_params` and/or `:connection_options` |
@@ -743,7 +817,7 @@ Define these in your client to change default behavior:
743
817
  | Method | Default |
744
818
  |--------|---------|
745
819
  | `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
746
- | `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP`: a new connection per request, or a pooled one with `ConnectionPools` |
820
+ | `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP`: a new connection per request, a pooled one with `ConnectionPools`, or the thread's own with `ThreadConnections` |
747
821
  | `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
748
822
  | `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
749
823
  | `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
@@ -5,6 +5,10 @@ require_relative 'client_api_builder/version'
5
5
  module ClientApiBuilder
6
6
  class Error < StandardError; end
7
7
 
8
+ # Raised by a transport when a request failed in a way that is safe to send again, e.g. the
9
+ # server refused it before processing it. Router#retry_request? retries these.
10
+ class RetryableError < Error; end
11
+
8
12
  class UnexpectedResponse < Error
9
13
  attr_reader :response
10
14
 
@@ -21,11 +25,13 @@ module ClientApiBuilder
21
25
  autoload :ActiveSupportNotifications, 'client_api_builder/active_support_notifications'
22
26
  autoload :ActiveSupportLogSubscriber, 'client_api_builder/active_support_log_subscriber'
23
27
  autoload :ConnectionPools, 'client_api_builder/connection_pools'
28
+ autoload :HTTP2, 'client_api_builder/http2'
24
29
  autoload :NestedRouter, 'client_api_builder/nested_router'
25
30
  autoload :QueryParams, 'client_api_builder/query_params'
26
31
  autoload :RouteValueValidator, 'client_api_builder/route_value_validator'
27
32
  autoload :Router, 'client_api_builder/router'
28
33
  autoload :Section, 'client_api_builder/section'
34
+ autoload :ThreadConnections, 'client_api_builder/thread_connections'
29
35
 
30
36
  module NetHTTP
31
37
  autoload :Request, 'client_api_builder/net_http_request'
@@ -26,6 +26,13 @@ module ClientApiBuilder
26
26
  unless base.include?(::ClientApiBuilder::Router)
27
27
  raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::ConnectionPools'
28
28
  end
29
+ if base.include?(::ClientApiBuilder::ThreadConnections)
30
+ raise ArgumentError, 'include either ClientApiBuilder::ConnectionPools or ClientApiBuilder::ThreadConnections'
31
+ end
32
+ # HTTP2 falls back to the pools through super, so it must come after them
33
+ if base.ancestors.any? { |mod| mod.name == 'ClientApiBuilder::HTTP2' }
34
+ raise ArgumentError, 'include ClientApiBuilder::ConnectionPools before ClientApiBuilder::HTTP2'
35
+ end
29
36
 
30
37
  base.extend ClassMethods
31
38
  base.connection_pool
@@ -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,71 @@
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 or ClientApiBuilder::ThreadConnections when using either:
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 pooled or per-thread connections)
17
+ # instead. http URLs always use HTTP/1.1. Responses are Net::HTTPResponse objects with
18
+ # http_version '2.0'.
19
+ module ClientApiBuilder
20
+ module HTTP2
21
+ autoload :Connection, 'client_api_builder/http2/connection'
22
+ autoload :ConnectionSet, 'client_api_builder/http2/connection_set'
23
+ autoload :Exchange, 'client_api_builder/http2/exchange'
24
+ autoload :RequestHeaders, 'client_api_builder/http2/request_headers'
25
+ autoload :ResponseBody, 'client_api_builder/http2/response_body'
26
+ autoload :ResponseBuilder, 'client_api_builder/http2/response_builder'
27
+ autoload :TLSSocket, 'client_api_builder/http2/tls_socket'
28
+
29
+ # The connection dropped (or was closed) before the response finished
30
+ class ConnectionLost < ::ClientApiBuilder::RetryableError; end
31
+
32
+ # The server refused the stream, or announced it would stop (GOAWAY) before processing it
33
+ class StreamRefused < ::ClientApiBuilder::RetryableError; end
34
+
35
+ # The server reset the stream with an error code
36
+ class StreamError < ::ClientApiBuilder::Error; end
37
+
38
+ # The server broke the HTTP/2 protocol; the connection is closed
39
+ class ProtocolError < ::ClientApiBuilder::Error; end
40
+
41
+ def self.included(base)
42
+ unless base.include?(::ClientApiBuilder::Router)
43
+ raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::HTTP2'
44
+ end
45
+
46
+ require_http2_gem
47
+ base.extend ClassMethods
48
+ base.redefine_class_method(:http2_connections, ConnectionSet.new)
49
+ end
50
+
51
+ def self.require_http2_gem
52
+ require 'http/2'
53
+ rescue LoadError
54
+ raise LoadError, "ClientApiBuilder::HTTP2 needs the http-2 gem; add gem 'http-2' to your Gemfile"
55
+ end
56
+
57
+ module ClassMethods
58
+ # Closes this class's HTTP/2 connections, and its pooled or per-thread connections when it
59
+ # has them
60
+ def close_connections
61
+ http2_connections.close
62
+ super if defined?(super)
63
+ end
64
+ end
65
+
66
+ def with_http_connection(uri, connection_options, &)
67
+ connection = uri.scheme == 'https' && self.class.http2_connections.connection_for(uri, connection_options)
68
+ connection ? yield(connection) : super
69
+ end
70
+ end
71
+ end
@@ -53,8 +53,16 @@ module ClientApiBuilder
53
53
  connection_pool(**settings)
54
54
  end
55
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
56
+ # Gives this section its own per-thread connections (see
57
+ # ThreadConnections.connection_per_thread), the same way connection_pool does
58
+ def self.connection_per_thread(**settings)
59
+ include ::ClientApiBuilder::ThreadConnections
60
+
61
+ connection_per_thread(**settings)
62
+ end
63
+
64
+ # Closes the connections of any nested sections that have their own; a section with its
65
+ # own uses ConnectionPools or ThreadConnections.close_connections, which close those as well
58
66
  def self.close_connections
59
67
  section_routers.each_value(&:close_connections)
60
68
  end
@@ -79,7 +87,8 @@ module ClientApiBuilder
79
87
  root_router.handle_response(response, options, &)
80
88
  end
81
89
 
82
- # Uses the root client's connections unless this section calls connection_pool
90
+ # Uses the root client's connections unless this section calls connection_pool or
91
+ # connection_per_thread
83
92
  def with_http_connection(uri, connection_options, &)
84
93
  root_router.with_http_connection(uri, connection_options, &)
85
94
  end
@@ -6,6 +6,9 @@ require 'json'
6
6
 
7
7
  module ClientApiBuilder
8
8
  module Router
9
+ # Status codes accepted when a route lists no expected_response_codes
10
+ SUCCESS_CODE = /\A2\d\d\z/
11
+
9
12
  def self.included(base)
10
13
  base.extend InheritanceHelper::Methods
11
14
  base.extend ClassMethods
@@ -578,8 +581,10 @@ module ClientApiBuilder
578
581
  url
579
582
  end
580
583
 
584
+ # Checks the status code rather than the response class, so any response object with a
585
+ # Net::HTTPResponse-style string code works (e.g. from a non-Net::HTTP transport)
581
586
  def expected_response_code!(response, expected_response_codes, _options)
582
- return if expected_response_codes.empty? && response.is_a?(Net::HTTPSuccess)
587
+ return if expected_response_codes.empty? && SUCCESS_CODE.match?(response.code.to_s)
583
588
  return if expected_response_codes.include?(response.code)
584
589
 
585
590
  raise(::ClientApiBuilder::UnexpectedResponse.new("unexpected response code #{response.code}", response))
@@ -686,7 +691,7 @@ module ClientApiBuilder
686
691
  def retry_request?(exception, _options)
687
692
  case exception
688
693
  when Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
689
- Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, EOFError
694
+ Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, EOFError, ::ClientApiBuilder::RetryableError
690
695
  true
691
696
  else
692
697
  false
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ module ThreadConnections
5
+ # A client class's connections, kept per thread: one per scheme, host, port and connection
6
+ # options, opened on the thread's first request and reused by its later ones.
7
+ #
8
+ # A connection is taken out of its thread's slot while a request uses it, so a request made
9
+ # before it's back (from a streaming block, or another fiber on the thread) opens one of its
10
+ # own; whichever finishes second is closed rather than kept. A connection that outlives the
11
+ # ttl, was opened before close, or whose request raised is closed instead of reused. The
12
+ # connections of threads that have died are closed when a thread makes its first request.
13
+ # After a fork the child starts with none; the parent's are dropped without being closed,
14
+ # since closing a shared TLS socket would disturb the parent.
15
+ class ConnectionSet
16
+ MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+
18
+ attr_reader :settings
19
+
20
+ def initialize(settings, clock: MONOTONIC_CLOCK)
21
+ @settings = settings
22
+ @clock = clock
23
+ reset
24
+ end
25
+
26
+ # Yields the current thread's Net::HTTP session and returns the block's result
27
+ def with_connection(uri, connection_options)
28
+ key = [uri.scheme, uri.hostname, uri.port, connection_options.dup.freeze].freeze
29
+ connection = checkout(key) || open_connection(uri, connection_options)
30
+ returned = false
31
+ begin
32
+ result = yield connection.http
33
+ checkin(key, connection)
34
+ returned = true
35
+ result
36
+ ensure
37
+ connection.close unless returned
38
+ end
39
+ end
40
+
41
+ # Closes idle connections now; connections in use are closed when their request ends
42
+ def close
43
+ idle = @mutex.synchronize do
44
+ @closed_at = now
45
+ remove_threads(@threads.keys)
46
+ end
47
+ idle.each(&:close)
48
+ end
49
+
50
+ # Idle connections, across all threads
51
+ def size
52
+ @mutex.synchronize { @threads.each_value.sum(&:size) }
53
+ end
54
+
55
+ private
56
+
57
+ def reset
58
+ @pid = Process.pid
59
+ @mutex = Mutex.new
60
+ @threads = {}
61
+ @closed_at = nil
62
+ end
63
+
64
+ def forked?
65
+ @pid != Process.pid
66
+ end
67
+
68
+ def now
69
+ @clock.call
70
+ end
71
+
72
+ # Returns the thread's connection for key, or nil when it has none to reuse. Retired
73
+ # connections are closed after the lock is released.
74
+ def checkout(key)
75
+ retired = []
76
+ reset if forked?
77
+ @mutex.synchronize do
78
+ retired.concat(remove_threads(dead_threads)) unless @threads.key?(Thread.current)
79
+ take(key, retired)
80
+ end
81
+ ensure
82
+ retired.each(&:close)
83
+ end
84
+
85
+ def take(key, retired)
86
+ connection = @threads[Thread.current]&.delete(key)
87
+ return connection unless connection && retire?(connection)
88
+
89
+ retired << connection
90
+ nil
91
+ end
92
+
93
+ def open_connection(uri, connection_options)
94
+ ConnectionPools::Connection.open(uri.hostname, uri.port,
95
+ { keep_alive_timeout: settings.idle_timeout }.merge(connection_options), now)
96
+ end
97
+
98
+ def checkin(key, connection)
99
+ connection.used(now)
100
+ kept = @mutex.synchronize { !retire?(connection) && keep?(key, connection) }
101
+ connection.close unless kept
102
+ end
103
+
104
+ # Keeps the connection for the thread's next request, unless a request that ran while
105
+ # this one did has already left one there
106
+ def keep?(key, connection)
107
+ connections = (@threads[Thread.current] ||= {})
108
+ return false if connections.key?(key)
109
+
110
+ connections[key] = connection
111
+ true
112
+ end
113
+
114
+ def dead_threads
115
+ @threads.keys.reject(&:alive?)
116
+ end
117
+
118
+ # Forgets the threads and returns their idle connections
119
+ def remove_threads(threads)
120
+ threads.flat_map { |thread| @threads.delete(thread).values }
121
+ end
122
+
123
+ def retire?(connection)
124
+ connection.expired?(settings.ttl, now) || closed_since_opened?(connection)
125
+ end
126
+
127
+ def closed_since_opened?(connection)
128
+ !@closed_at.nil? && connection.opened_at <= @closed_at
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClientApiBuilder
4
+ module ThreadConnections
5
+ # ttl: seconds a connection may live before it's closed and replaced
6
+ # idle_timeout: seconds a connection may sit unused before Net::HTTP reconnects it
7
+ # (Net::HTTP's keep_alive_timeout; a connection option overrides it)
8
+ Settings = Data.define(:ttl, :idle_timeout) do
9
+ def initialize(ttl: 30, idle_timeout: 2)
10
+ { ttl: ttl, idle_timeout: idle_timeout }.each do |name, value|
11
+ next if value.is_a?(Numeric) && value.positive?
12
+
13
+ raise ArgumentError, "#{name} must be a positive number of seconds, got #{value.inspect}"
14
+ end
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Purpose: opt-in persistent HTTP connections dedicated to each thread. Include after
4
+ # ClientApiBuilder::Router, instead of ClientApiBuilder::ConnectionPools:
5
+ #
6
+ # class MyClient
7
+ # include ClientApiBuilder::Router
8
+ # include ClientApiBuilder::ThreadConnections
9
+ #
10
+ # connection_per_thread ttl: 60
11
+ # end
12
+ #
13
+ # Each thread opens its own connection per host on its first request and reuses it for its
14
+ # later ones, so there's no pool size to match to the thread count and no waiting for a
15
+ # connection to free up.
16
+ module ClientApiBuilder
17
+ module ThreadConnections
18
+ autoload :ConnectionSet, 'client_api_builder/thread_connections/connection_set'
19
+ autoload :Settings, 'client_api_builder/thread_connections/settings'
20
+
21
+ def self.included(base)
22
+ unless base.include?(::ClientApiBuilder::Router)
23
+ raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::ThreadConnections'
24
+ end
25
+ if base.include?(::ClientApiBuilder::ConnectionPools)
26
+ raise ArgumentError, 'include either ClientApiBuilder::ConnectionPools or ClientApiBuilder::ThreadConnections'
27
+ end
28
+ # HTTP2 falls back to these connections through super, so it must come after them
29
+ if base.ancestors.any? { |mod| mod.name == 'ClientApiBuilder::HTTP2' }
30
+ raise ArgumentError, 'include ClientApiBuilder::ThreadConnections before ClientApiBuilder::HTTP2'
31
+ end
32
+
33
+ base.extend ClassMethods
34
+ base.connection_per_thread
35
+ end
36
+
37
+ module ClassMethods
38
+ # Configures this class's per-thread connections, replacing any it already had.
39
+ # Subclasses share their parent's unless they call connection_per_thread themselves.
40
+ def connection_per_thread(**settings)
41
+ redefine_class_method(:thread_connections, ConnectionSet.new(Settings.new(**settings)))
42
+ end
43
+
44
+ # Closes every thread's idle connections now and in-use ones when their request ends,
45
+ # including those of sections that have their own
46
+ def close_connections
47
+ thread_connections.close
48
+ section_routers.each_value(&:close_connections)
49
+ end
50
+ end
51
+
52
+ def with_http_connection(uri, connection_options, &)
53
+ self.class.thread_connections.with_connection(uri, connection_options, &)
54
+ end
55
+ end
56
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ClientApiBuilder
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.9.0'
5
+ VERSION = '0.11.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: client-api-builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -45,12 +45,23 @@ files:
45
45
  - lib/client_api_builder/connection_pools/pool.rb
46
46
  - lib/client_api_builder/connection_pools/pool_set.rb
47
47
  - lib/client_api_builder/connection_pools/settings.rb
48
+ - lib/client_api_builder/http2.rb
49
+ - lib/client_api_builder/http2/connection.rb
50
+ - lib/client_api_builder/http2/connection_set.rb
51
+ - lib/client_api_builder/http2/exchange.rb
52
+ - lib/client_api_builder/http2/request_headers.rb
53
+ - lib/client_api_builder/http2/response_body.rb
54
+ - lib/client_api_builder/http2/response_builder.rb
55
+ - lib/client_api_builder/http2/tls_socket.rb
48
56
  - lib/client_api_builder/nested_router.rb
49
57
  - lib/client_api_builder/net_http_request.rb
50
58
  - lib/client_api_builder/query_params.rb
51
59
  - lib/client_api_builder/route_value_validator.rb
52
60
  - lib/client_api_builder/router.rb
53
61
  - lib/client_api_builder/section.rb
62
+ - lib/client_api_builder/thread_connections.rb
63
+ - lib/client_api_builder/thread_connections/connection_set.rb
64
+ - lib/client_api_builder/thread_connections/settings.rb
54
65
  - lib/client_api_builder/version.rb
55
66
  homepage: https://github.com/dougyouch/client-api-builder
56
67
  licenses: