client-api-builder 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +114 -4
- data/lib/client-api-builder.rb +6 -0
- data/lib/client_api_builder/connection_pools/connection.rb +36 -0
- data/lib/client_api_builder/connection_pools/pool.rb +153 -0
- data/lib/client_api_builder/connection_pools/pool_set.rb +52 -0
- data/lib/client_api_builder/connection_pools/settings.rb +34 -0
- data/lib/client_api_builder/connection_pools.rb +57 -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 +69 -0
- data/lib/client_api_builder/nested_router.rb +20 -0
- data/lib/client_api_builder/net_http_request.rb +7 -1
- data/lib/client_api_builder/router.rb +40 -6
- data/lib/client_api_builder/section.rb +6 -0
- data/lib/client_api_builder/version.rb +1 -1
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d24a1d62d5ce8f7ace55ad6e7505fddb3663c51d032df031f4e058bb632b6f5e
|
|
4
|
+
data.tar.gz: a40b56b3f7ff04ecbe3507ea7ef9a7e5bbd8bc0aeb34ef9a0255038b62574948
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08cbffeca686d5299d7e294a25a750dc5ea7542ba017c3dbda817159b395bc51af51802f09326594b20968ac8f5628bf29d0e798bec84fddf4ff18c2e2613759'
|
|
7
|
+
data.tar.gz: 7a8476834697f1c8daf6498eeccd0a9fb256fd93b820e5fd407e586a49bdd3564459f005536b7eb0f29eba27c4925ffd8197289a77293df60310495f2c8c93a1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
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
|
+
|
|
16
|
+
## [0.9.0](https://github.com/dougyouch/client-api-builder/compare/v0.8.0...v0.9.0) (2026-10-04)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* add connection pools and exponential retry backoff ([e076162](https://github.com/dougyouch/client-api-builder/commit/e076162539aa9e92c1ec9bbdb5033de4cc163a14))
|
|
22
|
+
* **connection-pools:** add opt-in persistent connection pools ([d172764](https://github.com/dougyouch/client-api-builder/commit/d17276410b007d15d577693677bd17f0a4be0a27))
|
|
23
|
+
* **connection-pools:** add opt-in persistent connection pools ([df6fe98](https://github.com/dougyouch/client-api-builder/commit/df6fe9807ae9b0afb6b8f239ced15b6d5f984eea))
|
|
24
|
+
* **connection-pools:** let sections configure their own connection pools ([b0e17e3](https://github.com/dougyouch/client-api-builder/commit/b0e17e34efaeaa731f854e2e58348c5ae87c7cfa))
|
|
25
|
+
* **router:** add exponential backoff and jitter to retries ([59daf51](https://github.com/dougyouch/client-api-builder/commit/59daf51757f0b26d19064493cc499fc2cbef8954))
|
|
26
|
+
|
|
3
27
|
## [0.8.0](https://github.com/dougyouch/client-api-builder/compare/v0.7.2...v0.8.0) (2026-10-03)
|
|
4
28
|
|
|
5
29
|
|
data/README.md
CHANGED
|
@@ -15,6 +15,8 @@ A Ruby gem for building robust, secure API clients through declarative configura
|
|
|
15
15
|
- **Flexible Request Building** - Support for JSON, query params, and custom body builders
|
|
16
16
|
- **Nested Routing** - Organize complex APIs with hierarchical route structures
|
|
17
17
|
- **Retry Logic** - Configurable automatic retries for transient network failures
|
|
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)
|
|
18
20
|
- **Streaming Support** - Handle large payloads efficiently with streaming to files or IO
|
|
19
21
|
- **ActiveSupport Integration** - Optional logging and instrumentation
|
|
20
22
|
- **Comprehensive Error Handling** - Detailed error information for debugging
|
|
@@ -125,6 +127,9 @@ client.get_user(
|
|
|
125
127
|
connection_options: { read_timeout: 5 },
|
|
126
128
|
retries: 3, # attempts for this call
|
|
127
129
|
sleep: 0.5, # seconds between attempts
|
|
130
|
+
backoff: 2, # multiply the sleep by this after each retry
|
|
131
|
+
max_sleep: 5, # cap on the sleep between attempts
|
|
132
|
+
jitter: true, # randomly shorten each sleep (true, false or 0..1)
|
|
128
133
|
return: :body # :body or :response instead of parsed JSON
|
|
129
134
|
)
|
|
130
135
|
```
|
|
@@ -355,6 +360,85 @@ end
|
|
|
355
360
|
|
|
356
361
|
Any `Net::HTTP.start` option can be set this way. Your options are applied over the secure HTTPS defaults, so setting `verify_mode` yourself replaces `VERIFY_PEER`.
|
|
357
362
|
|
|
363
|
+
### Persistent Connections
|
|
364
|
+
|
|
365
|
+
By default each request opens and closes its own connection. Include `ClientApiBuilder::ConnectionPools` (after `Router`) to keep connections open and reuse them:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
class MyApiClient
|
|
369
|
+
include ClientApiBuilder::Router
|
|
370
|
+
include ClientApiBuilder::ConnectionPools
|
|
371
|
+
|
|
372
|
+
base_url 'https://api.example.com'
|
|
373
|
+
|
|
374
|
+
# Optional; these are the defaults
|
|
375
|
+
connection_pool max_connections: 5, # connections per host
|
|
376
|
+
ttl: 30, # seconds before a connection is closed and replaced
|
|
377
|
+
checkout_timeout: 5, # seconds to wait for a free connection
|
|
378
|
+
idle_timeout: 2 # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The pools live on the class, like ActiveRecord's, so every instance shares them: keep one client instance per thread and the threads share the connections. A connection is checked out for one request (or one streamed response) and returned straight after, so `max_connections` can be lower than your thread count. There is one pool per scheme, host, port and connection options.
|
|
383
|
+
|
|
384
|
+
- Sockets the server has closed, or that sat idle past `idle_timeout`, are reopened automatically.
|
|
385
|
+
- A connection whose request raised is closed rather than reused.
|
|
386
|
+
- When no connection frees up within `checkout_timeout`, the request raises `ClientApiBuilder::ConnectionPools::TimeoutError`.
|
|
387
|
+
- Sections use their root client's pools, getting a separate pool for any other host in their `base_url`. Subclasses share their parent's pools unless they call `connection_pool` themselves.
|
|
388
|
+
- A section can call `connection_pool` in its block to get its own pools and settings. This works whether or not the root client includes `ConnectionPools`:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
section :uploads do
|
|
392
|
+
base_url 'https://uploads.example.com'
|
|
393
|
+
connection_pool max_connections: 2, ttl: 10
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Sections nested inside it still fall back to the root client, as they do for `base_url`.
|
|
398
|
+
- After a fork (Puma, Sidekiq, Resque), the child process opens its own connections.
|
|
399
|
+
- `MyApiClient.close_connections` closes idle connections now and in-use ones when they're returned, including the pools of sections that have their own.
|
|
400
|
+
|
|
401
|
+
Pooling uses only `Net::HTTP` from the standard library.
|
|
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
|
+
|
|
358
442
|
### Retry Configuration
|
|
359
443
|
|
|
360
444
|
Configure automatic retries for transient failures:
|
|
@@ -370,7 +454,28 @@ class MyApiClient
|
|
|
370
454
|
end
|
|
371
455
|
```
|
|
372
456
|
|
|
373
|
-
The first argument is the total number of attempts, not extra retries. The default is 1, so requests are not retried unless you configure it.
|
|
457
|
+
The first argument is the total number of attempts, not extra retries. The default is 1, so requests are not retried unless you configure it.
|
|
458
|
+
|
|
459
|
+
For exponential backoff, use `configure_exponential_retries`:
|
|
460
|
+
|
|
461
|
+
```ruby
|
|
462
|
+
class MyApiClient
|
|
463
|
+
include ClientApiBuilder::Router
|
|
464
|
+
|
|
465
|
+
# Up to 5 attempts, sleeping 0.1s, 0.2s, 0.4s, 0.8s between them (never more than 10s)
|
|
466
|
+
configure_exponential_retries attempts: 5, initial: 0.1, max: 10
|
|
467
|
+
end
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
`initial` defaults to 0.1, `max` to 10 and the `multiplier` to 2. It is shorthand for `configure_retries 5, 0.1, backoff: 2, max_sleep: 10`: the sleep before retry n is `sleep * backoff**(n - 1)`, capped at `max_sleep`. `configure_retries` defaults to `backoff: 1` (a fixed sleep) and no cap.
|
|
471
|
+
|
|
472
|
+
Add `jitter:` so many clients that fail together don't all retry at the same moment. `jitter: true` sleeps a random time between 0 and the computed sleep; a number between 0 and 1 shortens it by up to that fraction (`jitter: 0.5` sleeps between 50% and 100% of it):
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
configure_exponential_retries attempts: 5, initial: 0.1, max: 10, jitter: true
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
`retries:`, `sleep:`, `backoff:`, `max_sleep:` and `jitter:` can also be passed per request.
|
|
374
479
|
|
|
375
480
|
Only these network errors are retried by default:
|
|
376
481
|
- `Net::OpenTimeout`, `Net::ReadTimeout`
|
|
@@ -618,7 +723,7 @@ Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `
|
|
|
618
723
|
|
|
619
724
|
## Thread Safety
|
|
620
725
|
|
|
621
|
-
Client instances are **not thread-safe**. Create a separate client instance per thread
|
|
726
|
+
Client instances are **not thread-safe**. Create a separate client instance per thread. To share connections between those instances, see [Persistent Connections](#persistent-connections).
|
|
622
727
|
|
|
623
728
|
```ruby
|
|
624
729
|
# Correct: Create a new client for each thread
|
|
@@ -651,7 +756,11 @@ end
|
|
|
651
756
|
| `query_builder(builder)` | Configure query string serialization |
|
|
652
757
|
| `query_param(name, value = nil, &block)` | Add a query parameter to all requests (value, method name symbol, or block) |
|
|
653
758
|
| `connection_option(name, value)` | Set Net::HTTP connection options |
|
|
654
|
-
| `configure_retries(max_attempts, sleep = 0.05)` | Configure retry behavior |
|
|
759
|
+
| `configure_retries(max_attempts, sleep = 0.05, backoff: 1, max_sleep: nil, jitter: false)` | Configure retry behavior |
|
|
760
|
+
| `configure_exponential_retries(attempts:, initial: 0.1, max: 10, multiplier: 2, jitter: false)` | Configure retries with exponential backoff |
|
|
761
|
+
| `connection_pool(**settings)` | Configure persistent connection pools (requires `include ClientApiBuilder::ConnectionPools`) |
|
|
762
|
+
| `close_connections` | Close the class's pooled connections and its sections' (with `ConnectionPools`) |
|
|
763
|
+
| `section_routers` | Section router classes by name |
|
|
655
764
|
| `route(name, path, options)` | Define an API endpoint |
|
|
656
765
|
| `section(name, options, &block)` | Define nested routes; `inherit:` opts into the root client's `:headers`, `:query_params` and/or `:connection_options` |
|
|
657
766
|
| `namespace(path, &block)` | Add path prefix to routes in block |
|
|
@@ -674,12 +783,13 @@ Define these in your client to change default behavior:
|
|
|
674
783
|
| Method | Default |
|
|
675
784
|
|--------|---------|
|
|
676
785
|
| `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
|
|
786
|
+
| `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP`: a new connection per request, or a pooled one with `ConnectionPools` |
|
|
677
787
|
| `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
|
|
678
788
|
| `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
|
|
679
789
|
| `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
|
|
680
790
|
| `expected_response_code!(response, codes, options)` | Raises `UnexpectedResponse` for unexpected codes |
|
|
681
791
|
| `get_retry_request_max_retries(options)` | `retries:` option, then `configure_retries`, then 1 |
|
|
682
|
-
| `get_retry_request_sleep_time(exception, options)` | `sleep:` option, then `configure_retries`, then 0.05 |
|
|
792
|
+
| `get_retry_request_sleep_time(exception, options)` | `sleep:` option, then `configure_retries`, then 0.05; multiplied by `backoff**(attempt - 1)`, capped at `max_sleep`, then shortened by `jitter` |
|
|
683
793
|
|
|
684
794
|
## Requirements
|
|
685
795
|
|
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
|
|
|
@@ -20,6 +24,8 @@ module ClientApiBuilder
|
|
|
20
24
|
|
|
21
25
|
autoload :ActiveSupportNotifications, 'client_api_builder/active_support_notifications'
|
|
22
26
|
autoload :ActiveSupportLogSubscriber, 'client_api_builder/active_support_log_subscriber'
|
|
27
|
+
autoload :ConnectionPools, 'client_api_builder/connection_pools'
|
|
28
|
+
autoload :HTTP2, 'client_api_builder/http2'
|
|
23
29
|
autoload :NestedRouter, 'client_api_builder/nested_router'
|
|
24
30
|
autoload :QueryParams, 'client_api_builder/query_params'
|
|
25
31
|
autoload :RouteValueValidator, 'client_api_builder/route_value_validator'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module ClientApiBuilder
|
|
6
|
+
module ConnectionPools
|
|
7
|
+
# A started Net::HTTP session and when it was opened and last used. Times come from the
|
|
8
|
+
# pool's monotonic clock. Net::HTTP reopens the socket itself if the server closed it or it
|
|
9
|
+
# sat idle past keep_alive_timeout.
|
|
10
|
+
class Connection
|
|
11
|
+
attr_reader :http, :opened_at, :last_used_at
|
|
12
|
+
|
|
13
|
+
def self.open(host, port, connection_options, now)
|
|
14
|
+
new(Net::HTTP.start(host, port, connection_options), now)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(http, now)
|
|
18
|
+
@http = http
|
|
19
|
+
@opened_at = now
|
|
20
|
+
@last_used_at = now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def used(now)
|
|
24
|
+
@last_used_at = now
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def expired?(ttl, now)
|
|
28
|
+
now - opened_at >= ttl
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def close
|
|
32
|
+
http.finish if http.started?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module ConnectionPools
|
|
5
|
+
# Thread-safe pool of connections to one host, sharing one set of connection options.
|
|
6
|
+
# Connections are opened on demand up to max_connections and reused most recently used
|
|
7
|
+
# first. One that outlives the ttl is closed when it's next checked out or returned, and
|
|
8
|
+
# one whose request raised is closed rather than reused.
|
|
9
|
+
class Pool
|
|
10
|
+
MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
11
|
+
|
|
12
|
+
attr_reader :host, :port, :settings
|
|
13
|
+
|
|
14
|
+
def initialize(host:, port:, connection_options:, settings:, clock: MONOTONIC_CLOCK)
|
|
15
|
+
@host = host
|
|
16
|
+
@port = port
|
|
17
|
+
@connection_options = connection_options
|
|
18
|
+
@settings = settings
|
|
19
|
+
@clock = clock
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@available = ConditionVariable.new
|
|
22
|
+
@idle = []
|
|
23
|
+
@size = 0
|
|
24
|
+
@closed_at = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yields a connection's Net::HTTP session and returns the block's result
|
|
28
|
+
def with_connection
|
|
29
|
+
connection = checkout
|
|
30
|
+
returned = false
|
|
31
|
+
begin
|
|
32
|
+
result = yield connection.http
|
|
33
|
+
checkin(connection)
|
|
34
|
+
returned = true
|
|
35
|
+
result
|
|
36
|
+
ensure
|
|
37
|
+
discard(connection) unless returned
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Closes idle connections now; connections in use are closed when they're returned
|
|
42
|
+
def close
|
|
43
|
+
idle = @mutex.synchronize do
|
|
44
|
+
@closed_at = now
|
|
45
|
+
@size -= @idle.size
|
|
46
|
+
@available.broadcast
|
|
47
|
+
@idle.slice!(0..)
|
|
48
|
+
end
|
|
49
|
+
idle.each(&:close)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Open connections, idle or in use
|
|
53
|
+
def size
|
|
54
|
+
@mutex.synchronize { @size }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def idle_size
|
|
58
|
+
@mutex.synchronize { @idle.size }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def now
|
|
64
|
+
@clock.call
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def checkout
|
|
68
|
+
reuse_or_reserve(now + settings.checkout_timeout) || open_connection
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns an idle connection, or nil once a slot is reserved for a new one.
|
|
72
|
+
# Expired idle connections are closed after the lock is released.
|
|
73
|
+
def reuse_or_reserve(deadline)
|
|
74
|
+
expired = []
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
loop do
|
|
77
|
+
connection = take_idle(expired)
|
|
78
|
+
return connection if connection
|
|
79
|
+
return nil if reserve_slot?
|
|
80
|
+
|
|
81
|
+
wait_for_connection(deadline)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
ensure
|
|
85
|
+
expired.each(&:close)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def take_idle(expired)
|
|
89
|
+
while (connection = @idle.pop)
|
|
90
|
+
return connection unless retire?(connection)
|
|
91
|
+
|
|
92
|
+
@size -= 1
|
|
93
|
+
expired << connection
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reserve_slot?
|
|
98
|
+
return false if @size >= settings.max_connections
|
|
99
|
+
|
|
100
|
+
@size += 1
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def wait_for_connection(deadline)
|
|
105
|
+
remaining = deadline - now
|
|
106
|
+
unless remaining.positive?
|
|
107
|
+
raise TimeoutError, "no connection to #{host}:#{port} became available within " \
|
|
108
|
+
"#{settings.checkout_timeout}s (max_connections: #{settings.max_connections})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@available.wait(@mutex, remaining)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def open_connection
|
|
115
|
+
connection = nil
|
|
116
|
+
connection = Connection.open(host, port, @connection_options, now)
|
|
117
|
+
ensure
|
|
118
|
+
release_slot unless connection
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def checkin(connection)
|
|
122
|
+
connection.used(now)
|
|
123
|
+
retired = @mutex.synchronize do
|
|
124
|
+
retire = retire?(connection)
|
|
125
|
+
retire ? @size -= 1 : @idle.push(connection)
|
|
126
|
+
@available.signal
|
|
127
|
+
retire
|
|
128
|
+
end
|
|
129
|
+
connection.close if retired
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def discard(connection)
|
|
133
|
+
release_slot
|
|
134
|
+
connection.close
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def release_slot
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
@size -= 1
|
|
140
|
+
@available.signal
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def retire?(connection)
|
|
145
|
+
connection.expired?(settings.ttl, now) || closed_since_opened?(connection)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def closed_since_opened?(connection)
|
|
149
|
+
!@closed_at.nil? && connection.opened_at <= @closed_at
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module ConnectionPools
|
|
5
|
+
# A client class's pools: one per scheme, host, port and connection options, created on
|
|
6
|
+
# first use. After a fork the child starts with no pools; the parent's connections are
|
|
7
|
+
# dropped without being closed, since closing a shared TLS socket would disturb the parent.
|
|
8
|
+
class PoolSet
|
|
9
|
+
attr_reader :settings
|
|
10
|
+
|
|
11
|
+
def initialize(settings)
|
|
12
|
+
@settings = settings
|
|
13
|
+
reset
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def with_connection(uri, connection_options, &)
|
|
17
|
+
pool_for(uri, connection_options).with_connection(&)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def pool_for(uri, connection_options)
|
|
21
|
+
reset if forked?
|
|
22
|
+
key = [uri.scheme, uri.hostname, uri.port, connection_options.dup.freeze].freeze
|
|
23
|
+
@mutex.synchronize { @pools[key] ||= build_pool(uri, connection_options) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pools
|
|
27
|
+
@mutex.synchronize { @pools.values }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
pools.each(&:close)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def reset
|
|
37
|
+
@pid = Process.pid
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@pools = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def forked?
|
|
43
|
+
@pid != Process.pid
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_pool(uri, connection_options)
|
|
47
|
+
Pool.new(host: uri.hostname, port: uri.port, settings: settings,
|
|
48
|
+
connection_options: { keep_alive_timeout: settings.idle_timeout }.merge(connection_options))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClientApiBuilder
|
|
4
|
+
module ConnectionPools
|
|
5
|
+
# max_connections: connections per host, in use or idle
|
|
6
|
+
# ttl: seconds a connection may live before it's closed and replaced
|
|
7
|
+
# checkout_timeout: seconds a thread waits for a free connection before TimeoutError
|
|
8
|
+
# idle_timeout: seconds a connection may sit unused before Net::HTTP reconnects it
|
|
9
|
+
# (Net::HTTP's keep_alive_timeout; a connection option overrides it)
|
|
10
|
+
Settings = Data.define(:max_connections, :ttl, :checkout_timeout, :idle_timeout) do
|
|
11
|
+
def initialize(max_connections: 5, ttl: 30, checkout_timeout: 5, idle_timeout: 2)
|
|
12
|
+
validate_max_connections!(max_connections)
|
|
13
|
+
{ ttl: ttl, checkout_timeout: checkout_timeout, idle_timeout: idle_timeout }.each do |name, value|
|
|
14
|
+
validate_seconds!(name, value)
|
|
15
|
+
end
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_max_connections!(value)
|
|
22
|
+
return if value.is_a?(Integer) && value.positive?
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "max_connections must be a positive Integer, got #{value.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_seconds!(name, value)
|
|
28
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "#{name} must be a positive number of seconds, got #{value.inspect}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Purpose: opt-in persistent HTTP connections shared by every instance of a client class.
|
|
4
|
+
# Include after ClientApiBuilder::Router:
|
|
5
|
+
#
|
|
6
|
+
# class MyClient
|
|
7
|
+
# include ClientApiBuilder::Router
|
|
8
|
+
# include ClientApiBuilder::ConnectionPools
|
|
9
|
+
#
|
|
10
|
+
# connection_pool max_connections: 10, ttl: 60
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Each thread uses its own client instance; the instances check connections out of the
|
|
14
|
+
# class's pools (one pool per host and connection options) for the length of one request.
|
|
15
|
+
module ClientApiBuilder
|
|
16
|
+
module ConnectionPools
|
|
17
|
+
autoload :Connection, 'client_api_builder/connection_pools/connection'
|
|
18
|
+
autoload :Pool, 'client_api_builder/connection_pools/pool'
|
|
19
|
+
autoload :PoolSet, 'client_api_builder/connection_pools/pool_set'
|
|
20
|
+
autoload :Settings, 'client_api_builder/connection_pools/settings'
|
|
21
|
+
|
|
22
|
+
# Raised when no connection frees up within checkout_timeout
|
|
23
|
+
class TimeoutError < ::ClientApiBuilder::Error; end
|
|
24
|
+
|
|
25
|
+
def self.included(base)
|
|
26
|
+
unless base.include?(::ClientApiBuilder::Router)
|
|
27
|
+
raise ArgumentError, 'include ClientApiBuilder::Router before ClientApiBuilder::ConnectionPools'
|
|
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
|
|
33
|
+
|
|
34
|
+
base.extend ClassMethods
|
|
35
|
+
base.connection_pool
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module ClassMethods
|
|
39
|
+
# Configures this class's pools, replacing any it already had. Subclasses share their
|
|
40
|
+
# parent's pools unless they call connection_pool themselves.
|
|
41
|
+
def connection_pool(**settings)
|
|
42
|
+
redefine_class_method(:connection_pools, PoolSet.new(Settings.new(**settings)))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Closes idle connections now and in-use ones when they are returned, including the
|
|
46
|
+
# pools of sections that have their own
|
|
47
|
+
def close_connections
|
|
48
|
+
connection_pools.close
|
|
49
|
+
section_routers.each_value(&:close_connections)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def with_http_connection(uri, connection_options, &)
|
|
54
|
+
self.class.connection_pools.with_connection(uri, connection_options, &)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|