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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aac435317feb174db101e201b00464222bfc2df0f10601ac88e7897c3694be27
4
- data.tar.gz: '08ad14efc0bff6c1f6e7fd4864e26c6f41853d72808fdc84b0e4781a0cfd229f'
3
+ metadata.gz: b12b7717adcfba57478c6ac76da774640924fa40dd67b9e570db305989de711b
4
+ data.tar.gz: 56ca42f80c9d6b0ae90cdfc131978421ef8cf7f1f80b9e0e96148fb0e23e95eb
5
5
  SHA512:
6
- metadata.gz: e28726cc8c9670553371ba62a3c2ea4d30a4edb5db413936b91810ab1b592b2b84e6dd0fd3bcac8ad47fa2fb2c1b5d57a6c0443cace95210b9cf203a5e783d43
7
- data.tar.gz: ed7628816de37ff4e8f4a27f67520f277abac439cb6183eca0c98ce5a072a1bd8c0413a3db5bda145d75208b4272c598b89514ad80f6692a6cb300c255330b64
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 :users do
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 headers, query params, connection options, retries and builders are not inherited, so declare the ones it needs inside the section. 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.
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. `retries:` and `sleep:` can also be passed per request.
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
 
@@ -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
- Net::HTTP.start(uri.hostname, uri.port, merged_options) do |http|
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
- def configure_retries(max_retries, sleep_time_between_retries_in_seconds = 0.05)
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 = self.class.default_headers.transform_values { |value| resolve_config_value(value) }
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
- if options[:connection_options]
494
- self.class.default_connection_options.merge(options[:connection_options])
495
- else
496
- self.class.default_connection_options
497
- end
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 = self.class.default_query_params.transform_values { |value| resolve_config_value(value) }
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
- def section(name, nested_router_options = {}, &)
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
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ClientApiBuilder
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.7.2'
5
+ VERSION = '0.9.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: client-api-builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
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