client-api-builder 0.7.2 → 0.9.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 +18 -0
- data/README.md +89 -9
- data/lib/client-api-builder.rb +1 -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 +53 -0
- data/lib/client_api_builder/nested_router.rb +62 -0
- data/lib/client_api_builder/net_http_request.rb +7 -1
- data/lib/client_api_builder/router.rb +52 -11
- data/lib/client_api_builder/section.rb +15 -3
- data/lib/client_api_builder/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b12b7717adcfba57478c6ac76da774640924fa40dd67b9e570db305989de711b
|
|
4
|
+
data.tar.gz: 56ca42f80c9d6b0ae90cdfc131978421ef8cf7f1f80b9e0e96148fb0e23e95eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a4f218c711148e498517406e1893aac38cbba09a64062086b0986b4c187b87220ebce08dcba04d33472282f0998c8c299ac66fa89bd4021dfef6a2bda82b1d2
|
|
7
|
+
data.tar.gz: 8ba48b3d89d982669e7f7e3a9e2bc5951c96c9c2530b48cd95befd3fff22aed089be12cd04919ccfe43b43801139c65bdbb21364059cde7d55af888e18009d1b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0](https://github.com/dougyouch/client-api-builder/compare/v0.8.0...v0.9.0) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add connection pools and exponential retry backoff ([e076162](https://github.com/dougyouch/client-api-builder/commit/e076162539aa9e92c1ec9bbdb5033de4cc163a14))
|
|
9
|
+
* **connection-pools:** add opt-in persistent connection pools ([d172764](https://github.com/dougyouch/client-api-builder/commit/d17276410b007d15d577693677bd17f0a4be0a27))
|
|
10
|
+
* **connection-pools:** add opt-in persistent connection pools ([df6fe98](https://github.com/dougyouch/client-api-builder/commit/df6fe9807ae9b0afb6b8f239ced15b6d5f984eea))
|
|
11
|
+
* **connection-pools:** let sections configure their own connection pools ([b0e17e3](https://github.com/dougyouch/client-api-builder/commit/b0e17e34efaeaa731f854e2e58348c5ae87c7cfa))
|
|
12
|
+
* **router:** add exponential backoff and jitter to retries ([59daf51](https://github.com/dougyouch/client-api-builder/commit/59daf51757f0b26d19064493cc499fc2cbef8954))
|
|
13
|
+
|
|
14
|
+
## [0.8.0](https://github.com/dougyouch/client-api-builder/compare/v0.7.2...v0.8.0) (2026-10-03)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **section:** let sections inherit root client headers, query params and connection options ([8849d98](https://github.com/dougyouch/client-api-builder/commit/8849d9875c86633639bf33c5e9b1eb486f67e921))
|
|
20
|
+
|
|
3
21
|
## [0.7.2](https://github.com/dougyouch/client-api-builder/compare/v0.7.1...v0.7.2) (2026-10-03)
|
|
4
22
|
|
|
5
23
|
|
data/README.md
CHANGED
|
@@ -15,6 +15,7 @@ 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
|
|
18
19
|
- **Streaming Support** - Handle large payloads efficiently with streaming to files or IO
|
|
19
20
|
- **ActiveSupport Integration** - Optional logging and instrumentation
|
|
20
21
|
- **Comprehensive Error Handling** - Detailed error information for debugging
|
|
@@ -125,6 +126,9 @@ client.get_user(
|
|
|
125
126
|
connection_options: { read_timeout: 5 },
|
|
126
127
|
retries: 3, # attempts for this call
|
|
127
128
|
sleep: 0.5, # seconds between attempts
|
|
129
|
+
backoff: 2, # multiply the sleep by this after each retry
|
|
130
|
+
max_sleep: 5, # cap on the sleep between attempts
|
|
131
|
+
jitter: true, # randomly shorten each sleep (true, false or 0..1)
|
|
128
132
|
return: :body # :body or :response instead of parsed JSON
|
|
129
133
|
)
|
|
130
134
|
```
|
|
@@ -296,9 +300,9 @@ class MyApiClient
|
|
|
296
300
|
"Bearer #{auth_token}"
|
|
297
301
|
end
|
|
298
302
|
|
|
299
|
-
section
|
|
303
|
+
# Use the root client's headers (Authorization) beneath the section's own
|
|
304
|
+
section :users, inherit: :headers do
|
|
300
305
|
base_url 'https://api.example.com/v2' # Override base URL
|
|
301
|
-
header 'Authorization', :authorization
|
|
302
306
|
|
|
303
307
|
route :list, '/users'
|
|
304
308
|
route :get, '/users/:id'
|
|
@@ -306,7 +310,7 @@ class MyApiClient
|
|
|
306
310
|
end
|
|
307
311
|
|
|
308
312
|
section :posts do
|
|
309
|
-
header 'Authorization', :authorization
|
|
313
|
+
header 'Authorization', :authorization # or declare what it needs itself
|
|
310
314
|
|
|
311
315
|
route :list, '/posts'
|
|
312
316
|
route :get, '/posts/:id'
|
|
@@ -322,7 +326,17 @@ user = client.users.get(id: 123)
|
|
|
322
326
|
posts = client.posts.list
|
|
323
327
|
```
|
|
324
328
|
|
|
325
|
-
A section is its own router class. It uses the parent's `base_url` unless it sets one, but
|
|
329
|
+
A section is its own router class. It uses the parent's `base_url` unless it sets one, but by default nothing else is inherited: declare the headers, query params and connection options it needs inside the section, or opt into the root client's with `inherit:`:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
section :users, inherit: %i[headers query_params connection_options] do
|
|
333
|
+
# or, inside the block: inherit_from_root :headers
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Inherited settings are the root client's class-level ones, read on every request (so ones declared after the section, or in a subclass of the client, apply too). The section's own settings override them, and per-request options override both; a per-request `nil` header still drops an inherited one. Retries and body/query builders are always the section's own. Any other options passed to `section` are available to it as `nested_router_options`.
|
|
338
|
+
|
|
339
|
+
Symbol and block values given to `header` and `query_param`, `{name}` path values, and response blocks are evaluated on the root client, so they can use its methods and state.
|
|
326
340
|
|
|
327
341
|
### Connection Options
|
|
328
342
|
|
|
@@ -345,6 +359,46 @@ end
|
|
|
345
359
|
|
|
346
360
|
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`.
|
|
347
361
|
|
|
362
|
+
### Persistent Connections
|
|
363
|
+
|
|
364
|
+
By default each request opens and closes its own connection. Include `ClientApiBuilder::ConnectionPools` (after `Router`) to keep connections open and reuse them:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
class MyApiClient
|
|
368
|
+
include ClientApiBuilder::Router
|
|
369
|
+
include ClientApiBuilder::ConnectionPools
|
|
370
|
+
|
|
371
|
+
base_url 'https://api.example.com'
|
|
372
|
+
|
|
373
|
+
# Optional; these are the defaults
|
|
374
|
+
connection_pool max_connections: 5, # connections per host
|
|
375
|
+
ttl: 30, # seconds before a connection is closed and replaced
|
|
376
|
+
checkout_timeout: 5, # seconds to wait for a free connection
|
|
377
|
+
idle_timeout: 2 # seconds idle before Net::HTTP reconnects (keep_alive_timeout)
|
|
378
|
+
end
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
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.
|
|
382
|
+
|
|
383
|
+
- Sockets the server has closed, or that sat idle past `idle_timeout`, are reopened automatically.
|
|
384
|
+
- A connection whose request raised is closed rather than reused.
|
|
385
|
+
- When no connection frees up within `checkout_timeout`, the request raises `ClientApiBuilder::ConnectionPools::TimeoutError`.
|
|
386
|
+
- 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.
|
|
387
|
+
- 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`:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
section :uploads do
|
|
391
|
+
base_url 'https://uploads.example.com'
|
|
392
|
+
connection_pool max_connections: 2, ttl: 10
|
|
393
|
+
end
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Sections nested inside it still fall back to the root client, as they do for `base_url`.
|
|
397
|
+
- After a fork (Puma, Sidekiq, Resque), the child process opens its own connections.
|
|
398
|
+
- `MyApiClient.close_connections` closes idle connections now and in-use ones when they're returned, including the pools of sections that have their own.
|
|
399
|
+
|
|
400
|
+
Pooling uses only `Net::HTTP` from the standard library.
|
|
401
|
+
|
|
348
402
|
### Retry Configuration
|
|
349
403
|
|
|
350
404
|
Configure automatic retries for transient failures:
|
|
@@ -360,7 +414,28 @@ class MyApiClient
|
|
|
360
414
|
end
|
|
361
415
|
```
|
|
362
416
|
|
|
363
|
-
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.
|
|
417
|
+
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.
|
|
418
|
+
|
|
419
|
+
For exponential backoff, use `configure_exponential_retries`:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
class MyApiClient
|
|
423
|
+
include ClientApiBuilder::Router
|
|
424
|
+
|
|
425
|
+
# Up to 5 attempts, sleeping 0.1s, 0.2s, 0.4s, 0.8s between them (never more than 10s)
|
|
426
|
+
configure_exponential_retries attempts: 5, initial: 0.1, max: 10
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`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.
|
|
431
|
+
|
|
432
|
+
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):
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
configure_exponential_retries attempts: 5, initial: 0.1, max: 10, jitter: true
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`retries:`, `sleep:`, `backoff:`, `max_sleep:` and `jitter:` can also be passed per request.
|
|
364
439
|
|
|
365
440
|
Only these network errors are retried by default:
|
|
366
441
|
- `Net::OpenTimeout`, `Net::ReadTimeout`
|
|
@@ -608,7 +683,7 @@ Only safe file modes are allowed for streaming to files: `w`, `wb`, `a`, `ab`, `
|
|
|
608
683
|
|
|
609
684
|
## Thread Safety
|
|
610
685
|
|
|
611
|
-
Client instances are **not thread-safe**. Create a separate client instance per thread
|
|
686
|
+
Client instances are **not thread-safe**. Create a separate client instance per thread. To share connections between those instances, see [Persistent Connections](#persistent-connections).
|
|
612
687
|
|
|
613
688
|
```ruby
|
|
614
689
|
# Correct: Create a new client for each thread
|
|
@@ -641,9 +716,13 @@ end
|
|
|
641
716
|
| `query_builder(builder)` | Configure query string serialization |
|
|
642
717
|
| `query_param(name, value = nil, &block)` | Add a query parameter to all requests (value, method name symbol, or block) |
|
|
643
718
|
| `connection_option(name, value)` | Set Net::HTTP connection options |
|
|
644
|
-
| `configure_retries(max_attempts, sleep = 0.05)` | Configure retry behavior |
|
|
719
|
+
| `configure_retries(max_attempts, sleep = 0.05, backoff: 1, max_sleep: nil, jitter: false)` | Configure retry behavior |
|
|
720
|
+
| `configure_exponential_retries(attempts:, initial: 0.1, max: 10, multiplier: 2, jitter: false)` | Configure retries with exponential backoff |
|
|
721
|
+
| `connection_pool(**settings)` | Configure persistent connection pools (requires `include ClientApiBuilder::ConnectionPools`) |
|
|
722
|
+
| `close_connections` | Close the class's pooled connections and its sections' (with `ConnectionPools`) |
|
|
723
|
+
| `section_routers` | Section router classes by name |
|
|
645
724
|
| `route(name, path, options)` | Define an API endpoint |
|
|
646
|
-
| `section(name, options, &block)` | Define nested routes |
|
|
725
|
+
| `section(name, options, &block)` | Define nested routes; `inherit:` opts into the root client's `:headers`, `:query_params` and/or `:connection_options` |
|
|
647
726
|
| `namespace(path, &block)` | Add path prefix to routes in block |
|
|
648
727
|
|
|
649
728
|
### Instance Methods
|
|
@@ -664,12 +743,13 @@ Define these in your client to change default behavior:
|
|
|
664
743
|
| Method | Default |
|
|
665
744
|
|--------|---------|
|
|
666
745
|
| `retry_request?(exception, options)` | `true` for the network errors listed under Retry Configuration |
|
|
746
|
+
| `with_http_connection(uri, connection_options, &block)` | Yields a started `Net::HTTP`: a new connection per request, or a pooled one with `ConnectionPools` |
|
|
667
747
|
| `escape_path(value)` | Percent-encodes path values (`ERB::Util.url_encode`) |
|
|
668
748
|
| `parse_response(response, options)` | Parses the body as JSON, `nil` when empty |
|
|
669
749
|
| `handle_response(response, options, &block)` | Applies `return:`, parsing and the response block |
|
|
670
750
|
| `expected_response_code!(response, codes, options)` | Raises `UnexpectedResponse` for unexpected codes |
|
|
671
751
|
| `get_retry_request_max_retries(options)` | `retries:` option, then `configure_retries`, then 1 |
|
|
672
|
-
| `get_retry_request_sleep_time(exception, options)` | `sleep:` option, then `configure_retries`, then 0.05 |
|
|
752
|
+
| `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` |
|
|
673
753
|
|
|
674
754
|
## Requirements
|
|
675
755
|
|
data/lib/client-api-builder.rb
CHANGED
|
@@ -20,6 +20,7 @@ module ClientApiBuilder
|
|
|
20
20
|
|
|
21
21
|
autoload :ActiveSupportNotifications, 'client_api_builder/active_support_notifications'
|
|
22
22
|
autoload :ActiveSupportLogSubscriber, 'client_api_builder/active_support_log_subscriber'
|
|
23
|
+
autoload :ConnectionPools, 'client_api_builder/connection_pools'
|
|
23
24
|
autoload :NestedRouter, 'client_api_builder/nested_router'
|
|
24
25
|
autoload :QueryParams, 'client_api_builder/query_params'
|
|
25
26
|
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,53 @@
|
|
|
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
|
+
|
|
30
|
+
base.extend ClassMethods
|
|
31
|
+
base.connection_pool
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module ClassMethods
|
|
35
|
+
# Configures this class's pools, replacing any it already had. Subclasses share their
|
|
36
|
+
# parent's pools unless they call connection_pool themselves.
|
|
37
|
+
def connection_pool(**settings)
|
|
38
|
+
redefine_class_method(:connection_pools, PoolSet.new(Settings.new(**settings)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Closes idle connections now and in-use ones when they are returned, including the
|
|
42
|
+
# pools of sections that have their own
|
|
43
|
+
def close_connections
|
|
44
|
+
connection_pools.close
|
|
45
|
+
section_routers.each_value(&:close_connections)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with_http_connection(uri, connection_options, &)
|
|
50
|
+
self.class.connection_pools.with_connection(uri, connection_options, &)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -8,6 +8,9 @@ module ClientApiBuilder
|
|
|
8
8
|
class NestedRouter
|
|
9
9
|
include ::ClientApiBuilder::Router
|
|
10
10
|
|
|
11
|
+
# Root client settings a section can opt into with inherit: or inherit_from_root
|
|
12
|
+
INHERITABLE_SETTINGS = %i[headers query_params connection_options].freeze
|
|
13
|
+
|
|
11
14
|
attr_reader :root_router,
|
|
12
15
|
:nested_router_options
|
|
13
16
|
|
|
@@ -20,6 +23,54 @@ module ClientApiBuilder
|
|
|
20
23
|
"\#{escape_path(root_router.#{var})}"
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
# The root client settings this section uses beneath its own; none by default
|
|
27
|
+
def self.inherited_root_settings
|
|
28
|
+
[].freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Opts this section into the root client's class-level headers, query params and/or
|
|
32
|
+
# connection options. They are read per request and the section's own values take precedence.
|
|
33
|
+
def self.inherit_from_root(*settings)
|
|
34
|
+
settings = normalize_inherited_settings(settings)
|
|
35
|
+
redefine_class_method(:inherited_root_settings, (inherited_root_settings | settings).freeze)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.normalize_inherited_settings(settings)
|
|
39
|
+
settings = settings.flatten.map(&:to_sym)
|
|
40
|
+
unknown = settings - INHERITABLE_SETTINGS
|
|
41
|
+
return settings if unknown.empty?
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "Unknown inherit setting(s): #{unknown.map(&:inspect).join(', ')}. " \
|
|
44
|
+
"Allowed: #{INHERITABLE_SETTINGS.map(&:inspect).join(', ')}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Gives this section its own connection pools (see ConnectionPools.connection_pool).
|
|
48
|
+
# Including ConnectionPools puts its connection_pool ahead of this one, so the call
|
|
49
|
+
# below configures the pools rather than recursing.
|
|
50
|
+
def self.connection_pool(**settings)
|
|
51
|
+
include ::ClientApiBuilder::ConnectionPools
|
|
52
|
+
|
|
53
|
+
connection_pool(**settings)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Closes the pools of any nested sections that have their own; a section with its own
|
|
57
|
+
# pools uses ConnectionPools.close_connections, which closes those as well
|
|
58
|
+
def self.close_connections
|
|
59
|
+
section_routers.each_value(&:close_connections)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def configured_headers
|
|
63
|
+
inherits_from_root?(:headers) ? root_router.configured_headers.merge(super) : super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def configured_query_params
|
|
67
|
+
inherits_from_root?(:query_params) ? root_router.configured_query_params.merge(super) : super
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configured_connection_options
|
|
71
|
+
inherits_from_root?(:connection_options) ? root_router.configured_connection_options.merge(super) : super
|
|
72
|
+
end
|
|
73
|
+
|
|
23
74
|
def base_url
|
|
24
75
|
self.class.base_url || root_router.base_url
|
|
25
76
|
end
|
|
@@ -27,5 +78,16 @@ module ClientApiBuilder
|
|
|
27
78
|
def handle_response(response, options, &)
|
|
28
79
|
root_router.handle_response(response, options, &)
|
|
29
80
|
end
|
|
81
|
+
|
|
82
|
+
# Uses the root client's connections unless this section calls connection_pool
|
|
83
|
+
def with_http_connection(uri, connection_options, &)
|
|
84
|
+
root_router.with_http_connection(uri, connection_options, &)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def inherits_from_root?(setting)
|
|
90
|
+
self.class.inherited_root_settings.include?(setting)
|
|
91
|
+
end
|
|
30
92
|
end
|
|
31
93
|
end
|
|
@@ -43,13 +43,19 @@ module ClientApiBuilder
|
|
|
43
43
|
ssl_options = uri.scheme == 'https' ? DEFAULT_SECURE_OPTIONS.merge(use_ssl: true) : {}
|
|
44
44
|
merged_options = ssl_options.merge(connection_options)
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
with_http_connection(uri, merged_options) do |http|
|
|
47
47
|
http.request(request) do |response|
|
|
48
48
|
yield response if block_given?
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Yields a started Net::HTTP session for one request. By default each request opens
|
|
54
|
+
# and closes its own connection; ConnectionPools overrides this to reuse pooled ones.
|
|
55
|
+
def with_http_connection(uri, connection_options, &)
|
|
56
|
+
Net::HTTP.start(uri.hostname, uri.port, connection_options, &)
|
|
57
|
+
end
|
|
58
|
+
|
|
53
59
|
# validate_response, when given, is called with the response before its body is streamed
|
|
54
60
|
# and raises to reject it. A rejected body is read into response.body instead of being
|
|
55
61
|
# streamed, so the error can still show it.
|
|
@@ -60,7 +60,10 @@ module ClientApiBuilder
|
|
|
60
60
|
query_params: {},
|
|
61
61
|
response_procs: {},
|
|
62
62
|
max_retries: 1,
|
|
63
|
-
sleep: 0.05
|
|
63
|
+
sleep: 0.05,
|
|
64
|
+
backoff: 1,
|
|
65
|
+
max_sleep: nil,
|
|
66
|
+
jitter: false
|
|
64
67
|
}.freeze
|
|
65
68
|
end
|
|
66
69
|
|
|
@@ -127,14 +130,27 @@ module ClientApiBuilder
|
|
|
127
130
|
add_value_to_class_method(:default_options, connection_options: connection_options)
|
|
128
131
|
end
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
# max_retries is the total number of attempts. The sleep before retry n is
|
|
134
|
+
# sleep * backoff**(n - 1), capped at max_sleep when given. jitter randomly
|
|
135
|
+
# shortens each sleep: true by up to all of it, a number (0..1) by up to that fraction.
|
|
136
|
+
def configure_retries(max_retries, sleep_time_between_retries_in_seconds = 0.05,
|
|
137
|
+
backoff: 1, max_sleep: nil, jitter: false)
|
|
131
138
|
add_value_to_class_method(
|
|
132
139
|
:default_options,
|
|
133
140
|
max_retries: max_retries,
|
|
134
|
-
sleep: sleep_time_between_retries_in_seconds
|
|
141
|
+
sleep: sleep_time_between_retries_in_seconds,
|
|
142
|
+
backoff: backoff,
|
|
143
|
+
max_sleep: max_sleep,
|
|
144
|
+
jitter: jitter
|
|
135
145
|
)
|
|
136
146
|
end
|
|
137
147
|
|
|
148
|
+
# configure_retries with a sleep that starts at initial and doubles (by default)
|
|
149
|
+
# after each failed attempt, never exceeding max
|
|
150
|
+
def configure_exponential_retries(attempts:, initial: 0.1, max: 10, multiplier: 2, jitter: false)
|
|
151
|
+
configure_retries(attempts, initial, backoff: multiplier, max_sleep: max, jitter: jitter)
|
|
152
|
+
end
|
|
153
|
+
|
|
138
154
|
# add a query param to all requests
|
|
139
155
|
def query_param(name, value = nil, &block)
|
|
140
156
|
query_params = deep_dup_hash(default_options[:query_params])
|
|
@@ -484,29 +500,41 @@ module ClientApiBuilder
|
|
|
484
500
|
# Class-level headers may be method names or blocks; per-request headers are used as given.
|
|
485
501
|
# Values are converted to strings, as Net::HTTP requires; nil values are left out of the request.
|
|
486
502
|
def build_headers(options)
|
|
487
|
-
headers =
|
|
503
|
+
headers = configured_headers
|
|
488
504
|
headers.merge!(options[:headers]) if options[:headers]
|
|
489
505
|
headers.transform_values { |value| value&.to_s }
|
|
490
506
|
end
|
|
491
507
|
|
|
508
|
+
# Class-level headers with symbols and blocks resolved; sections may add the root client's
|
|
509
|
+
def configured_headers
|
|
510
|
+
self.class.default_headers.transform_values { |value| resolve_config_value(value) }
|
|
511
|
+
end
|
|
512
|
+
|
|
492
513
|
def build_connection_options(options)
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
514
|
+
connection_options = configured_connection_options
|
|
515
|
+
options[:connection_options] ? connection_options.merge(options[:connection_options]) : connection_options
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Class-level connection options; sections may add the root client's
|
|
519
|
+
def configured_connection_options
|
|
520
|
+
self.class.default_connection_options
|
|
498
521
|
end
|
|
499
522
|
|
|
500
523
|
# Class-level query params may be method names or blocks; values from route arguments
|
|
501
524
|
# and per-request options are sent as given.
|
|
502
525
|
def build_query(query, options)
|
|
503
|
-
query_params =
|
|
526
|
+
query_params = configured_query_params
|
|
504
527
|
query_params.merge!(query) if query
|
|
505
528
|
query_params.merge!(options[:query]) if options[:query]
|
|
506
529
|
|
|
507
530
|
query_params.empty? ? nil : self.class.build_query(self, query_params)
|
|
508
531
|
end
|
|
509
532
|
|
|
533
|
+
# Class-level query params with symbols and blocks resolved; sections may add the root client's
|
|
534
|
+
def configured_query_params
|
|
535
|
+
self.class.default_query_params.transform_values { |value| resolve_config_value(value) }
|
|
536
|
+
end
|
|
537
|
+
|
|
510
538
|
# Resolves a class-level header or query_param value: a Symbol calls that method and a
|
|
511
539
|
# Proc is evaluated, both on the root router; anything else is used as is.
|
|
512
540
|
def resolve_config_value(value)
|
|
@@ -623,7 +651,20 @@ module ClientApiBuilder
|
|
|
623
651
|
end
|
|
624
652
|
|
|
625
653
|
def get_retry_request_sleep_time(_exception, options)
|
|
626
|
-
options[:sleep] || self.class.default_options[:sleep] || 0.05
|
|
654
|
+
sleep_time = options[:sleep] || self.class.default_options[:sleep] || 0.05
|
|
655
|
+
backoff = options[:backoff] || self.class.default_options[:backoff] || 1
|
|
656
|
+
sleep_time *= backoff**(@request_attempts - 1)
|
|
657
|
+
max_sleep = options[:max_sleep] || self.class.default_options[:max_sleep]
|
|
658
|
+
sleep_time = [sleep_time, max_sleep].min if max_sleep
|
|
659
|
+
apply_retry_jitter(sleep_time, options.fetch(:jitter) { self.class.default_options[:jitter] })
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# shortens sleep_time by a random amount: up to all of it for true, up to that fraction for a number
|
|
663
|
+
def apply_retry_jitter(sleep_time, jitter)
|
|
664
|
+
return sleep_time unless jitter
|
|
665
|
+
|
|
666
|
+
jitter = 1 if jitter == true
|
|
667
|
+
sleep_time * (1 - (jitter * rand))
|
|
627
668
|
end
|
|
628
669
|
|
|
629
670
|
def get_retry_request_max_retries(options)
|
|
@@ -10,21 +10,33 @@ module ClientApiBuilder
|
|
|
10
10
|
module ClassMethods
|
|
11
11
|
SECTION_NAME = /\A[a-z_][a-z0-9_]*\z/i
|
|
12
12
|
|
|
13
|
+
# Section router classes by name, including inherited sections
|
|
14
|
+
def section_routers
|
|
15
|
+
{}.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
13
18
|
# Defines <name>_router (the section's NestedRouter class) and <name> (its router for a
|
|
14
19
|
# client instance) with closures, so anonymous client classes and any option values work.
|
|
15
|
-
|
|
20
|
+
# inherit: opts the section into root client settings (see NestedRouter.inherit_from_root);
|
|
21
|
+
# the remaining options are passed to the section as nested_router_options.
|
|
22
|
+
def section(name, nested_router_options = {}, &block)
|
|
16
23
|
raise ArgumentError, "Invalid section name: #{name.inspect}" unless name.to_s.match?(SECTION_NAME)
|
|
17
24
|
|
|
25
|
+
nested_router_options = nested_router_options.dup
|
|
26
|
+
inherit = ::ClientApiBuilder::NestedRouter.normalize_inherited_settings(Array(nested_router_options.delete(:inherit)))
|
|
27
|
+
|
|
18
28
|
kls = InheritanceHelper::ClassBuilder::Utils.create_class(
|
|
19
29
|
self,
|
|
20
30
|
name,
|
|
21
31
|
::ClientApiBuilder::NestedRouter,
|
|
22
32
|
nil,
|
|
23
|
-
'NestedRouter'
|
|
24
|
-
&
|
|
33
|
+
'NestedRouter'
|
|
25
34
|
)
|
|
35
|
+
kls.inherit_from_root(inherit)
|
|
36
|
+
kls.class_eval(&block) if block
|
|
26
37
|
|
|
27
38
|
define_singleton_method(:"#{name}_router") { kls }
|
|
39
|
+
add_value_to_class_method(:section_routers, name.to_sym => kls)
|
|
28
40
|
define_section_accessor(name, nested_router_options)
|
|
29
41
|
end
|
|
30
42
|
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -40,6 +40,11 @@ files:
|
|
|
40
40
|
- lib/client-api-builder.rb
|
|
41
41
|
- lib/client_api_builder/active_support_log_subscriber.rb
|
|
42
42
|
- lib/client_api_builder/active_support_notifications.rb
|
|
43
|
+
- lib/client_api_builder/connection_pools.rb
|
|
44
|
+
- lib/client_api_builder/connection_pools/connection.rb
|
|
45
|
+
- lib/client_api_builder/connection_pools/pool.rb
|
|
46
|
+
- lib/client_api_builder/connection_pools/pool_set.rb
|
|
47
|
+
- lib/client_api_builder/connection_pools/settings.rb
|
|
43
48
|
- lib/client_api_builder/nested_router.rb
|
|
44
49
|
- lib/client_api_builder/net_http_request.rb
|
|
45
50
|
- lib/client_api_builder/query_params.rb
|