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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +76 -2
- data/lib/client-api-builder.rb +6 -0
- data/lib/client_api_builder/connection_pools.rb +7 -0
- data/lib/client_api_builder/http2/connection.rb +187 -0
- data/lib/client_api_builder/http2/connection_set.rb +61 -0
- data/lib/client_api_builder/http2/exchange.rb +76 -0
- data/lib/client_api_builder/http2/request_headers.rb +38 -0
- data/lib/client_api_builder/http2/response_body.rb +54 -0
- data/lib/client_api_builder/http2/response_builder.rb +45 -0
- data/lib/client_api_builder/http2/tls_socket.rb +80 -0
- data/lib/client_api_builder/http2.rb +71 -0
- data/lib/client_api_builder/nested_router.rb +12 -3
- data/lib/client_api_builder/router.rb +7 -2
- data/lib/client_api_builder/thread_connections/connection_set.rb +132 -0
- data/lib/client_api_builder/thread_connections/settings.rb +19 -0
- data/lib/client_api_builder/thread_connections.rb +56 -0
- data/lib/client_api_builder/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5a8b4e5fcad31bffb2d4234fb2d73205ae0e3aed33ba75ec32defa778ed456f
|
|
4
|
+
data.tar.gz: ff349350509add94a7108af7dddf9416b98265752735a510d46d35bc97897849
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| `
|
|
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,
|
|
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 |
|
data/lib/client-api-builder.rb
CHANGED
|
@@ -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
|
-
#
|
|
57
|
-
#
|
|
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? &&
|
|
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
|
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.
|
|
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:
|