client-api-builder 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b12b7717adcfba57478c6ac76da774640924fa40dd67b9e570db305989de711b
4
- data.tar.gz: 56ca42f80c9d6b0ae90cdfc131978421ef8cf7f1f80b9e0e96148fb0e23e95eb
3
+ metadata.gz: d24a1d62d5ce8f7ace55ad6e7505fddb3663c51d032df031f4e058bb632b6f5e
4
+ data.tar.gz: a40b56b3f7ff04ecbe3507ea7ef9a7e5bbd8bc0aeb34ef9a0255038b62574948
5
5
  SHA512:
6
- metadata.gz: 1a4f218c711148e498517406e1893aac38cbba09a64062086b0986b4c187b87220ebce08dcba04d33472282f0998c8c299ac66fa89bd4021dfef6a2bda82b1d2
7
- data.tar.gz: 8ba48b3d89d982669e7f7e3a9e2bc5951c96c9c2530b48cd95befd3fff22aed089be12cd04919ccfe43b43801139c65bdbb21364059cde7d55af888e18009d1b
6
+ metadata.gz: '08cbffeca686d5299d7e294a25a750dc5ea7542ba017c3dbda817159b395bc51af51802f09326594b20968ac8f5628bf29d0e798bec84fddf4ff18c2e2613759'
7
+ data.tar.gz: 7a8476834697f1c8daf6498eeccd0a9fb256fd93b820e5fd407e586a49bdd3564459f005536b7eb0f29eba27c4925ffd8197289a77293df60310495f2c8c93a1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/dougyouch/client-api-builder/compare/v0.9.0...v0.10.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * add opt-in HTTP/2 support ([48c6f6e](https://github.com/dougyouch/client-api-builder/commit/48c6f6e37b9c346d2e8b70db30a72661ea18358e))
9
+ * **http2:** add opt-in HTTP/2 support ([10ee136](https://github.com/dougyouch/client-api-builder/commit/10ee13677d1914dd37dd5efb2afee9779e2fdbda))
10
+
11
+
12
+ ### Performance Improvements
13
+
14
+ * **http2:** add HTTP/1.1 vs HTTP/2 benchmark script ([29b9add](https://github.com/dougyouch/client-api-builder/commit/29b9adde11ae6dcf55a5f2eedf0c1826774bb51a))
15
+
3
16
  ## [0.9.0](https://github.com/dougyouch/client-api-builder/compare/v0.8.0...v0.9.0) (2026-10-04)
4
17
 
5
18
 
data/README.md CHANGED
@@ -16,6 +16,7 @@ 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
+ - **HTTP/2** - Opt-in HTTP/2 over TLS, multiplexing concurrent requests on one connection per host (with the `http-2` gem)
19
20
  - **Streaming Support** - Handle large payloads efficiently with streaming to files or IO
20
21
  - **ActiveSupport Integration** - Optional logging and instrumentation
21
22
  - **Comprehensive Error Handling** - Detailed error information for debugging
@@ -399,6 +400,45 @@ The pools live on the class, like ActiveRecord's, so every instance shares them:
399
400
 
400
401
  Pooling uses only `Net::HTTP` from the standard library.
401
402
 
403
+ ### HTTP/2
404
+
405
+ Include `ClientApiBuilder::HTTP2` (after `Router`, and after `ConnectionPools` if you use both) 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:
406
+
407
+ ```ruby
408
+ # Gemfile
409
+ gem 'http-2'
410
+ ```
411
+
412
+ ```ruby
413
+ class MyApiClient
414
+ include ClientApiBuilder::Router
415
+ include ClientApiBuilder::ConnectionPools # optional: used for servers that only speak HTTP/1.1
416
+ include ClientApiBuilder::HTTP2
417
+
418
+ base_url 'https://api.example.com'
419
+ end
420
+ ```
421
+
422
+ 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.
423
+
424
+ - 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 connection pools if the class includes `ConnectionPools`. `http://` URLs always use HTTP/1.1.
425
+ - 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.
426
+ - When the server's limit on concurrent streams is reached, a request waits up to `read_timeout` for a stream to free up.
427
+ - Gzip and deflate responses are inflated, as `Net::HTTP` does, unless you set `Accept-Encoding` yourself.
428
+ - 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`.
429
+ - Response data is acknowledged to the server as it arrives, so a streaming consumer slower than the server buffers the difference in memory.
430
+ - 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 pools).
431
+
432
+ **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):
433
+
434
+ | Scenario (1000 requests) | New connection | Pooled | HTTP/2 |
435
+ |---|---|---|---|
436
+ | 1 thread, no server delay | 501 req/s | 4,824 req/s | 2,057 req/s |
437
+ | 50 threads, 20ms delay, one pooled connection per thread | 628 req/s | 1,904 req/s | 1,765 req/s |
438
+ | 50 threads, 20ms delay, default pool of 5 | 640 req/s | 208 req/s | 1,706 req/s |
439
+
440
+ 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 the pools a connection per thread, they're usually as fast or faster.
441
+
402
442
  ### Retry Configuration
403
443
 
404
444
  Configure automatic retries for transient failures:
@@ -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,6 +25,7 @@ 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'
@@ -26,6 +26,10 @@ module ClientApiBuilder
26
26
  unless base.include?(::ClientApiBuilder::Router)
27
27
  raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::ConnectionPools'
28
28
  end
29
+ # HTTP2 falls back to the pools through super, so it must come after them
30
+ if base.ancestors.any? { |mod| mod.name == 'ClientApiBuilder::HTTP2' }
31
+ raise ArgumentError, 'include ClientApiBuilder::ConnectionPools before ClientApiBuilder::HTTP2'
32
+ end
29
33
 
30
34
  base.extend ClassMethods
31
35
  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,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
@@ -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
@@ -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.10.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.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -45,6 +45,14 @@ 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