http_connection_pool 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dcde2d73c540f08460cedccdbe23d1b963a6efadb10572932b33c27ba7e2ad24
4
+ data.tar.gz: 01e68de6a8cff18d5aa24479a78342016fbf11d424a21a612e28158114a7ebbc
5
+ SHA512:
6
+ metadata.gz: 56d902f8c8cec43e09a689c934f7f6c2632db1066f107abbfebc7c85fa1b114f6f83c5ec3e3c83bf15444377b68fa24c7bb8874211328237ca579d0360e7b22a
7
+ data.tar.gz: 1d134c8c37cec6111439e1761dcd6e9e88efccdb138805ce37c906ba8b19492b12e41547b5f58d705724f25ca2fcb8e02ddb417f35e44f1db17d148ebae03b56
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bbarberBPL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,490 @@
1
+ # HttpConnectionPool
2
+
3
+ Thread-safe (and Fiber-scheduler-aware) persistent HTTP connection pooling for
4
+ the [http.rb](https://github.com/httprb/http) gem.
5
+
6
+ `HttpConnectionPool` keeps **one pool of persistent `HTTP::Session` connections
7
+ per URL origin** (scheme + host + port) and hands them out to threads or fibers
8
+ on demand. It is built on top of the battle-tested
9
+ [`connection_pool`](https://github.com/mperham/connection_pool) gem and uses
10
+ [`concurrent-ruby`](https://github.com/ruby-concurrency/concurrent-ruby)
11
+ primitives for its registry, so checkouts are safe under heavy concurrency
12
+ without you having to manage sockets, mutexes, or keep-alive state yourself.
13
+
14
+ On http.rb v6, `HTTP.persistent` returns an `HTTP::Session`, and http.rb's own
15
+ README notes that a persistent session is **not** thread-safe on its own —
16
+ it recommends pairing it with the `connection_pool` gem. That is exactly what
17
+ this gem does, with an origin-keyed registry, a `Connectable` mixin, and
18
+ credential-isolated pools layered on top.
19
+
20
+ ## Features
21
+
22
+ - **Persistent connections** — reuses keep-alive `HTTP::Session` connections
23
+ instead of opening a fresh socket per request.
24
+ - **One pool per origin** — a global registry guarantees a single shared pool
25
+ for every `scheme://host:port`, normalised automatically from any URL.
26
+ - **Thread-safe & fiber-aware** — backed by `connection_pool`, so a blocking
27
+ checkout yields to the fiber scheduler when one is active instead of parking
28
+ the OS thread.
29
+ - **Bounded with timeouts** — configurable pool size and checkout timeout;
30
+ exhausted pools raise `HttpConnectionPool::Pool::TimeoutError` rather than
31
+ blocking forever.
32
+ - **`Connectable` mixin** — drop into any service/API client class (or `extend`
33
+ onto a module) for a clean `with_connection { |conn| ... }` API.
34
+ - **Introspectable** — `#stats` exposes pool size, checked-out, and idle counts.
35
+
36
+ ## Requirements
37
+
38
+ - Ruby `>= 3.3.0`. Tested on **MRI (CRuby)**. JRuby support is planned but
39
+ currently **untested** — `http.rb` selects its parser by engine
40
+ ([`llhttp`](https://rubygems.org/gems/llhttp), a native C extension, on MRI
41
+ and `llhttp-ffi` on JRuby), so JRuby installs are not blocked, just not yet
42
+ verified.
43
+ - On MRI, a C compiler/toolchain is needed at install time, since the `llhttp`
44
+ extension is compiled during `gem install` / `bundle install`. On
45
+ Debian/Ubuntu, for example, install `build-essential`; on macOS, the Xcode
46
+ Command Line Tools.
47
+
48
+ ### Dependency tree
49
+
50
+ This gem pulls in the following runtime dependencies:
51
+
52
+ | Gem | Version constraint | Notes |
53
+ | ----------------- | ------------------------ | -------------------------------------- |
54
+ | `http` | `~> 6.0` | The underlying http.rb client |
55
+ | `connection_pool` | `>= 2.5.5, < 3` | Generic, fiber-aware pooling primitive |
56
+ | `concurrent-ruby` | `>= 1.3.7, ~> 1.3` | Lock-free registry & atomics; floor fixes CVE-2026-54904/54905/54906 |
57
+
58
+ `http.rb` in turn brings in `http-cookie`, `domain_name`, and its parser
59
+ (`llhttp` on MRI, `llhttp-ffi` on JRuby). All are pure Ruby except the native
60
+ `llhttp` build used on MRI.
61
+
62
+ ## Installation
63
+
64
+ Add it to your application's Gemfile:
65
+
66
+ ```ruby
67
+ gem 'http_connection_pool'
68
+ ```
69
+
70
+ Then run:
71
+
72
+ ```bash
73
+ bundle install
74
+ ```
75
+
76
+ Or install it directly:
77
+
78
+ ```bash
79
+ gem install http_connection_pool
80
+ ```
81
+
82
+ ## Usage
83
+
84
+ ### The `Connectable` mixin (recommended)
85
+
86
+ Include `HttpConnectionPool::Connectable` in a client class and configure it
87
+ with a base URL. Each class sharing a base URL transparently shares one pool.
88
+
89
+ ```ruby
90
+ require 'http_connection_pool'
91
+
92
+ class GithubClient
93
+ include HttpConnectionPool::Connectable
94
+
95
+ self.base_url = 'https://api.github.com'
96
+ self.pool_size = 10
97
+ self.pool_timeout = 3.0
98
+ self.pool_options = { headers: { 'Authorization' => "Bearer #{ENV['GITHUB_TOKEN']}" } }
99
+
100
+ def user(login)
101
+ with_connection { |conn| conn.get("/users/#{login}").parse }
102
+ end
103
+ end
104
+
105
+ GithubClient.new.user('bbarberBPL')
106
+ ```
107
+
108
+ You can also `extend` it onto a module for a class-method-only API:
109
+
110
+ ```ruby
111
+ module GithubAPI
112
+ extend HttpConnectionPool::Connectable
113
+
114
+ self.base_url = 'https://api.github.com'
115
+
116
+ def self.user(login)
117
+ with_connection { |conn| conn.get("/users/#{login}").parse }
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Using the registry directly
123
+
124
+ If you don't want the mixin, reach for the registry. It returns (and caches) a
125
+ pool for any URL's origin:
126
+
127
+ ```ruby
128
+ registry = HttpConnectionPool::Registry.instance
129
+
130
+ registry.pool_for('https://api.example.com').with do |conn|
131
+ conn.get('/status').parse
132
+ end
133
+ ```
134
+
135
+ ### Configuration options
136
+
137
+ `pool_options` (or the keyword args to `pool_for`) configure every
138
+ `HTTP::Session` in the pool. Most are set on the underlying `HTTP::Options`
139
+ when the session is built; `timeout` and `proxy` use http.rb's chainable
140
+ translation:
141
+
142
+ | Option | How it is applied | Example |
143
+ | ------------- | ------------------------------------------ | --------------------------------------------- |
144
+ | `:headers` | `HTTP::Options` `headers` field | `{ headers: { 'Accept' => 'application/json' } }` |
145
+ | `:auth` | folded into an `Authorization` header | `{ auth: 'Bearer token' }` |
146
+ | `:ssl` | `HTTP::Options` `ssl` field | `{ ssl: { ca_file: '/path/ca.pem' } }` |
147
+ | `:timeout` | `HTTP::Session#timeout` (chainable) | `{ timeout: 5 }` |
148
+ | `:proxy` | `HTTP::Session#via` (chainable) | `{ proxy: ['proxy.example.com', 8080] }` |
149
+ | `:ssl_context`| not supported — raises `OptionKeyError` | use `:ssl` instead |
150
+
151
+ Request paths are resolved against the pool's origin, so pass relative paths
152
+ (`conn.get('/users/1')`) — they target the pool's `scheme://host:port` (the
153
+ origin of `base_url` when using the `Connectable` mixin).
154
+
155
+ > **Note (http.rb v6):** `:ssl_context` is not supported. An `OpenSSL::SSL::SSLContext`
156
+ > cannot be safely used as a pool key — two contexts that differ only in a
157
+ > write-only field (e.g. `min_version`/`max_version`, which OpenSSL does not let
158
+ > us read back) would key to the same pool and silently share connections. So
159
+ > passing `:ssl_context` raises `HttpConnectionPool::OptionKeyError`. Configure
160
+ > TLS via the `:ssl` hash (`ssl: { ca_file: ..., verify_mode: ... }`) instead.
161
+
162
+ ### One pool per (origin + options), and credential isolation
163
+
164
+ Pools are keyed by a **SHA-256 digest of the origin and options**, so two
165
+ callers that target the same host but supply different credentials each get
166
+ their own isolated pool. There is no error, no interference, and no shared
167
+ connections:
168
+
169
+ ```ruby
170
+ # Each token gets its own pool — no conflict.
171
+ registry.pool_for('https://api.example.com', headers: { 'Authorization' => 'Bearer aaa' })
172
+ registry.pool_for('https://api.example.com', headers: { 'Authorization' => 'Bearer bbb' })
173
+ ```
174
+
175
+ Requesting the same origin with **identical** options returns the cached pool
176
+ without allocating a new one.
177
+
178
+ This design also makes subclassing safe. A subclass that overrides
179
+ `pool_options` receives its own isolated pool; a subclass that leaves
180
+ `pool_options` untouched shares the parent's pool:
181
+
182
+ ```ruby
183
+ class BaseClient
184
+ include HttpConnectionPool::Connectable
185
+ self.base_url = 'https://api.example.com'
186
+ self.pool_size = 10
187
+ end
188
+
189
+ class AdminClient < BaseClient
190
+ # Inherits base_url and pool_size; gets a separate pool for admin credentials.
191
+ self.pool_options = { headers: { 'Authorization' => "Bearer #{ENV['ADMIN_TOKEN']}" } }
192
+ end
193
+
194
+ class ReadOnlyClient < BaseClient
195
+ self.pool_options = { headers: { 'Authorization' => "Bearer #{ENV['READONLY_TOKEN']}" } }
196
+ end
197
+
198
+ # BaseClient, AdminClient, and ReadOnlyClient each have their own pool.
199
+ # BaseClient.connection_pool — no auth
200
+ # AdminClient.connection_pool — admin token, never mixed with read-only
201
+ # ReadOnlyClient.connection_pool — read-only token, never mixed with admin
202
+ ```
203
+
204
+ > **Note:** `pool_options` on a subclass *replaces* the parent's options
205
+ > entirely — it does not merge them. If you need to add headers on top of a
206
+ > parent's defaults, merge explicitly:
207
+ > ```ruby
208
+ > self.pool_options = BaseClient.pool_options.merge(
209
+ > headers: BaseClient.pool_options.fetch(:headers, {}).merge(
210
+ > 'X-Extra' => 'value'
211
+ > )
212
+ > )
213
+ > ```
214
+
215
+ **Option-hash key ordering** does not matter — `{ 'X-A' => '1', 'X-B' => '2' }`
216
+ and `{ 'X-B' => '2', 'X-A' => '1' }` are treated as the same options and
217
+ return the same pool. The registry normalises nested hashes before hashing.
218
+
219
+ **Host casing** does not matter either — `https://API.Example.com` and
220
+ `https://api.example.com` resolve to the same origin (and pool), since DNS
221
+ hostnames are case-insensitive.
222
+
223
+ When you `release` a pool you must pass the same options so the registry can
224
+ locate the correct key:
225
+
226
+ ```ruby
227
+ registry.release('https://api.example.com', headers: { 'Authorization' => 'Bearer aaa' })
228
+ ```
229
+
230
+ Credentials are kept out of `#inspect`, `#to_s`, and `pp` output for both the
231
+ pool and the registry:
232
+
233
+ ```ruby
234
+ pool.inspect
235
+ # => #<HttpConnectionPool::Pool origin="https://api.github.com:443" size=10 \
236
+ # timeout=3.0 closed=false options=[headers, auth]>
237
+
238
+ HttpConnectionPool::Registry.instance.inspect
239
+ # => #<HttpConnectionPool::Registry pools=3 max_pools=unlimited>
240
+ ```
241
+
242
+ ### Bounding the number of pools
243
+
244
+ By default the registry holds an unbounded number of pools — one per distinct
245
+ origin. If origins can be influenced by **untrusted input** (webhook targets,
246
+ redirect hosts, user-supplied URLs), cap the registry so a flood of unique
247
+ origins can't exhaust memory or file descriptors:
248
+
249
+ ```ruby
250
+ # Per-registry:
251
+ registry = HttpConnectionPool::Registry.new(max_pools: 100)
252
+
253
+ # Or for the process-wide singleton, before first use (e.g. a Rails initializer):
254
+ HttpConnectionPool::Registry.configure(max_pools: 100)
255
+ ```
256
+
257
+ Creating a pool for a *new* origin beyond the cap raises
258
+ `HttpConnectionPool::Registry::PoolLimitError`; reusing an existing origin is
259
+ never blocked, and `release`-ing a pool frees a slot. The cap is a soft limit —
260
+ under heavy concurrency the count may briefly overshoot by the number of
261
+ distinct origins racing to be created, but growth stays bounded.
262
+
263
+ ### Inspecting pool state
264
+
265
+ ```ruby
266
+ # Stats for a single pool:
267
+ pool = GithubClient.connection_pool
268
+ pool.stats
269
+ # => { origin: "https://api.github.com:443", size: 10,
270
+ # checked_out: 0, idle: 10, closed: false }
271
+
272
+ # Stats for all pools in the registry (Array, one entry per pool):
273
+ HttpConnectionPool::Registry.instance.stats
274
+ # => [
275
+ # { origin: "https://api.github.com:443", size: 10, ... },
276
+ # { origin: "https://api.example.com:443", size: 5, ... },
277
+ # ]
278
+ ```
279
+
280
+ ### Shutting pools down
281
+
282
+ ```ruby
283
+ GithubClient.release_connection_pool # close one class's pool
284
+ HttpConnectionPool::Registry.instance.close_all # close every pool
285
+ ```
286
+
287
+ Prefer `release_connection_pool` / `Registry#release` / `close_all`, which both
288
+ close the pool **and** remove it from the registry. Calling `Pool#close`
289
+ directly on a pool you obtained from the registry closes its connections but
290
+ leaves the dead entry in the registry until its exact key is requested again —
291
+ the entry keeps its slot under [`max_pools`](#bounding-the-number-of-pools)
292
+ until then. A long-running process that closes pools out-of-band can reclaim
293
+ them all at once:
294
+
295
+ ```ruby
296
+ HttpConnectionPool::Registry.instance.sweep_closed! # evict closed pools, returns count
297
+ ```
298
+
299
+
300
+ ### Forking app servers (Puma, Unicorn, Spring, Resque, Sidekiq)
301
+
302
+ Clustered Puma, Unicorn, and other preforking servers boot the app **once in a
303
+ parent process** and then `fork` worker processes. A network socket must never
304
+ be shared across a fork — two processes reading and writing the same TLS/HTTP
305
+ connection will corrupt each other's streams.
306
+
307
+ **The good news:** this gem's backing `connection_pool` (>= 2.5) is fork-aware.
308
+ It defaults to `auto_reload_after_fork: true` and hooks `Process._fork`, so a
309
+ freshly forked worker automatically **discards any inherited connections and
310
+ opens its own** on first checkout. You do not need to do anything for
311
+ correctness — there is no risk of workers sharing a socket.
312
+
313
+ What is still worth doing is **hygiene**: proactively close inherited pools in
314
+ each worker so you start from a clean slate and don't briefly retain the
315
+ parent's (now-defunct) connection objects. Every server exposes an
316
+ `after_fork`/`on_worker_boot` hook for exactly this:
317
+
318
+ ```ruby
319
+ # Puma — config/puma.rb
320
+ on_worker_boot do
321
+ HttpConnectionPool::Registry.instance.close_all
322
+ end
323
+
324
+ # Unicorn — config/unicorn.rb
325
+ after_fork do |_server, _worker|
326
+ HttpConnectionPool::Registry.instance.close_all
327
+ end
328
+ ```
329
+
330
+ ```ruby
331
+ # Resque
332
+ Resque.after_fork { HttpConnectionPool::Registry.instance.close_all }
333
+
334
+ # Sidekiq runs jobs in threads, not forks, so no per-job reset is needed; the
335
+ # shared pool is what you want there.
336
+ ```
337
+
338
+ The parent process is unaffected by a worker closing its own copy — each forked
339
+ worker gets its own copy-on-write view of the singleton registry. You can pair
340
+ this with `Registry.configure(max_pools:)` (see above) in the parent's boot so
341
+ every worker inherits the same ceiling.
342
+
343
+ It is also good practice to close pools on graceful shutdown so connections are
344
+ released promptly rather than waiting on GC / socket timeouts:
345
+
346
+ ```ruby
347
+ at_exit { HttpConnectionPool::Registry.instance.close_all }
348
+ ```
349
+
350
+ ## Security
351
+
352
+ Keeping a persistent connection open means any header configured on the pool
353
+ (`auth`, `Authorization`, `Cookie`) is reused for every request on that
354
+ connection. Two practices keep that from leaking:
355
+
356
+ - **Never build a request path from untrusted input without validating it.**
357
+ A protocol-relative path (`//evil-host/path`) can be interpreted as a
358
+ network-path reference that replaces the origin's authority, redirecting the
359
+ request — and its connection-scoped credentials — to an attacker-controlled
360
+ host. As a defensive measure, reject request paths that begin with `//`
361
+ before passing them to `with_connection`.
362
+ - **Cap the registry when origins come from untrusted input** — see
363
+ [Bounding the number of pools](#bounding-the-number-of-pools).
364
+
365
+ Credentials are also kept out of `#inspect`, `#to_s`, and `pp` output for both
366
+ the pool and the registry (origin, size, and option *keys* only — never option
367
+ values). See
368
+ [credential isolation](#one-pool-per-origin--options-and-credential-isolation).
369
+
370
+ ## Error handling
371
+
372
+ Every error raised by the pool/registry layer descends from a single root, so
373
+ one rescue catches them all:
374
+
375
+ | Error | Raised when |
376
+ | -------------------------------------- | ------------------------------------------------ |
377
+ | `HttpConnectionPool::TimeoutError` | No connection available within the checkout timeout |
378
+ | `HttpConnectionPool::ClosedError` | A closed pool is used |
379
+ | `HttpConnectionPool::PoolLimitError` | A new pool would exceed `max_pools` |
380
+ | `HttpConnectionPool::InvalidURLError` | A URL has no/unsupported scheme or no host |
381
+ | `HttpConnectionPool::OptionKeyError` | An option value cannot be used as a pool key |
382
+
383
+ `InvalidURLError` and `OptionKeyError` are both `ConfigurationError`, which is
384
+ itself a `HttpConnectionPool::Error`:
385
+
386
+ ```ruby
387
+ begin
388
+ client.with_connection { |conn| conn.get('/status') }
389
+ rescue HttpConnectionPool::Error => e
390
+ # any pool/registry-layer failure
391
+ end
392
+ ```
393
+
394
+ The legacy constants `Pool::TimeoutError`, `Pool::ClosedError`, and
395
+ `Registry::PoolLimitError` still work — they are aliases of the classes above.
396
+
397
+ **Request errors pass through.** A request you make inside the block
398
+ (`conn.get(...)`) is yours: any `HTTP::Error` (timeouts, connection failures,
399
+ status errors) propagates **unchanged**, because the pool does not own your
400
+ request semantics. Rescue `HttpConnectionPool::Error` for pool/registry
401
+ failures and `HTTP::Error` for request failures.
402
+
403
+ ## Rails compatibility
404
+
405
+ This gem works inside Rails (verified against the **7.2.x** series) but does
406
+ **not** depend on Rails — it stays usable in any plain-Ruby project. Rails and
407
+ this gem share two dependencies, and the version constraints overlap cleanly:
408
+
409
+ | Shared dep | Rails 7.2 requires | This gem requires |
410
+ | ----------------- | --------------------- | ------------------ |
411
+ | `concurrent-ruby` | `~> 1.0, >= 1.3.1` | `~> 1.3` |
412
+ | `connection_pool` | `>= 2.2.5` | `>= 2.5.5, < 3` |
413
+
414
+ Compatibility is enforced in CI: `activesupport` is pulled into the **test
415
+ group only** (never the gemspec), and `spec/integration/rails_compatibility_spec.rb`
416
+ asserts the resolved dependency versions satisfy both Rails and this gem, and
417
+ that the `Connectable` mixin behaves correctly under a Rails-style service
418
+ object (including across class reloads). To test a newer Rails, bump the
419
+ `activesupport` pin in the Gemfile and re-run the suite.
420
+
421
+ ### Zeitwerk
422
+
423
+ The gem loads its own constants with plain `require_relative`, so it is
424
+ invisible to — and safe for — a host Rails app's Zeitwerk loader, even under
425
+ `eager_load` in production. Its file/constant layout is nonetheless fully
426
+ Zeitwerk-conformant: `spec/integration/zeitwerk_compliance_spec.rb` eager-loads
427
+ the gem through a real `Zeitwerk::Loader` (in a clean process) and fails if any
428
+ file/constant naming ever drifts. Like every gem, only `version.rb` is exempt
429
+ (it defines `VERSION`, not `Version`). Zeitwerk is a **test-only** dependency,
430
+ never a runtime one.
431
+
432
+ ### Background jobs
433
+
434
+ The gem is also verified inside background jobs:
435
+ `spec/integration/background_job_spec.rb` runs the `Connectable` pool through a
436
+ bare `Sidekiq::Job`, an Active Job on the `:test` adapter, and an Active Job on
437
+ the `:sidekiq` adapter (all under `Sidekiq::Testing.inline!`, no Redis). It
438
+ asserts that jobs hitting one origin share a single pool, that job classes with
439
+ different credentials get isolated pools, that a connection is returned to the
440
+ pool when a job raises, and that neither the registry nor the live `Pool` count
441
+ grows with job count. Sidekiq and Active Job are **test-only** dependencies.
442
+
443
+ ## Development
444
+
445
+ After checking out the repo, install dependencies and run the test suite:
446
+
447
+ ```bash
448
+ bin/setup # bundle install
449
+ bundle exec rake # runs RuboCop, then RSpec (the default `ci` task)
450
+ ```
451
+
452
+ For an interactive sandbox with the gem and an `EXAMPLE` client preloaded:
453
+
454
+ ```bash
455
+ bin/console
456
+ ```
457
+
458
+ ```ruby
459
+ >> EXAMPLE.with_connection { |conn| conn.get('/get').status }
460
+ >> EXAMPLE.connection_pool_stats
461
+ ```
462
+
463
+ ### Examples
464
+
465
+ The [`examples/`](examples/) directory has runnable, real-backend examples that
466
+ are not part of the gem package. [`examples/solr_client.rb`](examples/solr_client.rb)
467
+ is a `Connectable` client for a Solr 8.11.x core, and
468
+ [`examples/solr_update_demo.rb`](examples/solr_update_demo.rb) walks an
469
+ add/update/read/delete round-trip through the pool. See
470
+ [`examples/README.md`](examples/README.md) for how to run them.
471
+
472
+ ### Building and publishing
473
+
474
+ ```bash
475
+ bundle exec rake build # build the gem into pkg/ (gitignored)
476
+ bundle exec rake build:checksum # build, then write SHA-256 + SHA-512 to checksums/
477
+ ```
478
+
479
+ `rake build:checksum` records both digests under `checksums/` in the standard
480
+ `sha256sum -c` / `sha512sum -c` format, so a published artifact can be verified
481
+ against this repository. The built `.gem` is never committed; only its
482
+ checksums are.
483
+
484
+ Publishing to RubyGems is a manual, maintainer-only step — this project
485
+ deliberately ships no automated push task. Regenerate the checksums whenever
486
+ the version changes, immediately before publishing.
487
+
488
+ ## License
489
+
490
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'registry'
4
+
5
+ module HttpConnectionPool
6
+ # Mixin that gives any class a `connection_pool` class-level accessor and a
7
+ # `with_connection` method for safely borrowing a persistent HTTP client.
8
+ #
9
+ # ── Include (instance + class API) ──────────────────────────────────────────
10
+ #
11
+ # class GithubClient
12
+ # include HttpConnectionPool::Connectable
13
+ #
14
+ # self.base_url = 'https://api.github.com'
15
+ # self.pool_size = 10
16
+ # self.pool_timeout = 3.0
17
+ # self.pool_options = { headers: { 'Authorization' => "Bearer #{ENV['GITHUB_TOKEN']}" } }
18
+ #
19
+ # def user(login)
20
+ # with_connection { |conn| conn.get("/users/#{login}").parse }
21
+ # end
22
+ # end
23
+ #
24
+ # ── Subclassing ─────────────────────────────────────────────────────────────
25
+ #
26
+ # Subclasses inherit base_url, pool_size, pool_timeout, and pool_options from
27
+ # their parent. Each class that declares its own pool_options receives its own
28
+ # isolated pool (keyed by origin + options digest), so a subclass that adds
29
+ # credentials never shares connections with the base class or siblings:
30
+ #
31
+ # class AdminClient < GithubClient
32
+ # self.pool_options = { headers: { 'Authorization' => "Bearer #{ENV['ADMIN_TOKEN']}" } }
33
+ # end
34
+ #
35
+ # ── Extend (class-level methods only) ───────────────────────────────────────
36
+ #
37
+ # module GithubAPI
38
+ # extend HttpConnectionPool::Connectable
39
+ #
40
+ # self.base_url = 'https://api.github.com'
41
+ #
42
+ # def self.user(login)
43
+ # with_connection { |conn| conn.get("/users/#{login}").parse }
44
+ # end
45
+ # end
46
+ #
47
+ module Connectable
48
+ # Called when the module is included into a class.
49
+ def self.included(base)
50
+ base.extend(ClassMethods)
51
+ base.extend(PoolAccessors)
52
+ end
53
+
54
+ # Called when the module is extended onto an object/module.
55
+ def self.extended(base)
56
+ base.extend(ClassMethods)
57
+ base.extend(PoolAccessors)
58
+ end
59
+
60
+ # Class-level configuration attributes.
61
+ # Readers walk the ancestor chain so subclasses inherit configuration
62
+ # without having to restate it; the writer pins the value on that class.
63
+ module PoolAccessors
64
+ attr_writer :base_url, :pool_size, :pool_timeout, :pool_options
65
+
66
+ def base_url
67
+ # Walk superclass chain until we find a set value.
68
+ klass = self
69
+ while klass
70
+ return klass.instance_variable_get(:@base_url) if
71
+ klass.instance_variable_defined?(:@base_url) && klass.instance_variable_get(:@base_url)
72
+
73
+ klass = klass.respond_to?(:superclass) ? klass.superclass : nil
74
+ end
75
+
76
+ raise NotImplementedError,
77
+ "#{name || inspect} must set `self.base_url = <url>` before using the connection pool"
78
+ end
79
+
80
+ def pool_size
81
+ klass = self
82
+ while klass
83
+ return klass.instance_variable_get(:@pool_size) if
84
+ klass.instance_variable_defined?(:@pool_size)
85
+
86
+ klass = klass.respond_to?(:superclass) ? klass.superclass : nil
87
+ end
88
+ Pool::DEFAULT_SIZE
89
+ end
90
+
91
+ def pool_timeout
92
+ klass = self
93
+ while klass
94
+ return klass.instance_variable_get(:@pool_timeout) if
95
+ klass.instance_variable_defined?(:@pool_timeout)
96
+
97
+ klass = klass.respond_to?(:superclass) ? klass.superclass : nil
98
+ end
99
+ Pool::DEFAULT_TIMEOUT
100
+ end
101
+
102
+ def pool_options
103
+ klass = self
104
+ while klass
105
+ return klass.instance_variable_get(:@pool_options) if
106
+ klass.instance_variable_defined?(:@pool_options)
107
+
108
+ klass = klass.respond_to?(:superclass) ? klass.superclass : nil
109
+ end
110
+ {}
111
+ end
112
+ end
113
+
114
+ # Class-level behaviour available after include/extend.
115
+ module ClassMethods
116
+ # Borrow a connection from the pool and yield it to the block.
117
+ #
118
+ # @yieldparam conn [HTTP::Session] a persistent session pre-configured for base_url
119
+ # @return [Object] the return value of the block
120
+ def with_connection(&)
121
+ connection_pool.with(&)
122
+ end
123
+
124
+ # Lazy accessor for the pool — initialised on first call and memoized so
125
+ # the request hot path avoids re-parsing the URL and re-allocating the
126
+ # options hash on every `with_connection`. The memo is dropped whenever
127
+ # the underlying pool has been closed (e.g. via the registry), so the
128
+ # next call transparently obtains a fresh pool.
129
+ #
130
+ # @return [HttpConnectionPool::Pool]
131
+ def connection_pool
132
+ cached = @connection_pool
133
+ return cached if cached && !cached.closed?
134
+
135
+ @connection_pool = HttpConnectionPool::Registry.instance.pool_for(
136
+ base_url,
137
+ size: pool_size,
138
+ timeout: pool_timeout,
139
+ **pool_options
140
+ )
141
+ end
142
+
143
+ # Explicitly release and close this class's pool.
144
+ # The next call to `with_connection` will open a fresh pool.
145
+ def release_connection_pool
146
+ @connection_pool = nil
147
+ HttpConnectionPool::Registry.instance.release(base_url, **pool_options)
148
+ end
149
+
150
+ # Snapshot of the pool's current stats.
151
+ #
152
+ # @return [Hash]
153
+ def connection_pool_stats
154
+ connection_pool.stats
155
+ end
156
+ end
157
+
158
+ # ── Instance-level proxy methods (only meaningful after `include`) ────────
159
+
160
+ # @see ClassMethods#with_connection
161
+ def with_connection(&)
162
+ self.class.with_connection(&)
163
+ end
164
+
165
+ # @see ClassMethods#connection_pool
166
+ def connection_pool
167
+ self.class.connection_pool
168
+ end
169
+
170
+ # @see ClassMethods#connection_pool_stats
171
+ def connection_pool_stats
172
+ self.class.connection_pool_stats
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpConnectionPool
4
+ # Root of every error raised by this gem's pool/registry layer. Rescue this to
5
+ # catch any failure that originates here. Request-body errors from http.rb
6
+ # (HTTP::Error and subclasses) are NOT remapped — they propagate raw, since a
7
+ # request made inside a `with`/`with_connection` block is the caller's own.
8
+ class Error < StandardError; end
9
+
10
+ # Unusable configuration, detected before any I/O (URL validation, option
11
+ # keyability). All ConfigurationError subclasses are raised at setup time.
12
+ class ConfigurationError < Error; end
13
+
14
+ # A URL had no scheme, an unsupported scheme, or no host.
15
+ class InvalidURLError < ConfigurationError; end
16
+
17
+ # An option value cannot be safely/canonically used as part of a pool key
18
+ # (e.g. an SSLContext object, whose inspect omits distinguishing material and
19
+ # would silently collide). See README "Error handling".
20
+ class OptionKeyError < ConfigurationError; end
21
+
22
+ # Creating a new pool would exceed the registry's max_pools cap.
23
+ class PoolLimitError < Error; end
24
+
25
+ # No connection became available within the checkout timeout.
26
+ class TimeoutError < Error; end
27
+
28
+ # A pool was used after it was closed.
29
+ class ClosedError < Error; end
30
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Require only the concurrent-ruby primitives we use rather than the whole
4
+ # library — this keeps load time and memory footprint down (the same approach
5
+ # Rails takes).
6
+ require 'concurrent/atomic/atomic_boolean'
7
+ require 'connection_pool'
8
+ require 'http'
9
+ require_relative 'errors'
10
+
11
+ module HttpConnectionPool
12
+ # Manages a pool of persistent HTTP::Session connections for a single URL
13
+ # origin. On http v6, HTTP.persistent returns an HTTP::Session, and http's own
14
+ # README notes that a persistent session is not thread-safe on its own — it
15
+ # points to the `connection_pool` gem for thread-safe persistent use. That is
16
+ # exactly the pattern this class follows: each caller checks out its own
17
+ # Session for the duration of a request.
18
+ #
19
+ # Backed by the `connection_pool` gem (>= 2.5.5), which is both thread- and
20
+ # Fiber.scheduler-aware: when running under a fiber scheduler, blocking
21
+ # checkouts yield to the scheduler instead of parking the OS thread.
22
+ #
23
+ # Pool instances are never created directly — obtain them through Registry.
24
+ class Pool
25
+ # Backward-compatible aliases — the canonical classes live in errors.rb
26
+ # under HttpConnectionPool. Existing `rescue Pool::TimeoutError` keeps working.
27
+ TimeoutError = HttpConnectionPool::TimeoutError
28
+ ClosedError = HttpConnectionPool::ClosedError
29
+
30
+ DEFAULT_SIZE = 5
31
+ DEFAULT_TIMEOUT = 5.0 # seconds to wait for a connection to become available
32
+
33
+ # @param origin [String] canonical origin, e.g. "https://api.example.com:443"
34
+ # @param size [Integer] maximum number of concurrent connections
35
+ # @param timeout [Float] seconds to block waiting for a free connection
36
+ # @param options [Hash] options forwarded to every HTTP::Session (headers, timeout, ssl, etc.)
37
+ def initialize(origin:, size: DEFAULT_SIZE, timeout: DEFAULT_TIMEOUT, **options)
38
+ @origin = origin
39
+ @size = Integer(size)
40
+ @timeout = Float(timeout)
41
+ @options = deep_freeze(options)
42
+
43
+ raise ArgumentError, 'size must be >= 1' unless @size >= 1
44
+ raise ArgumentError, 'timeout must be > 0' unless @timeout.positive?
45
+
46
+ @closed = Concurrent::AtomicBoolean.new(false)
47
+ @pool = ::ConnectionPool.new(size: @size, timeout: @timeout) { build_connection }
48
+ end
49
+
50
+ attr_reader :origin, :size
51
+
52
+ # Yields a live HTTP::Session scoped to @origin, returning it to the pool when done.
53
+ #
54
+ # @raise [ClosedError] if the pool has been shut down
55
+ # @raise [TimeoutError] if no connection is available within the configured timeout
56
+ # @yieldparam conn [HTTP::Session]
57
+ # @return [Object] the value returned by the block
58
+ def with(&)
59
+ raise ClosedError, "pool for #{@origin} is closed" if @closed.true?
60
+
61
+ @pool.with(&)
62
+ rescue ::ConnectionPool::TimeoutError => e
63
+ raise TimeoutError, "no connection available for #{@origin} within #{@timeout}s (#{e.message})"
64
+ rescue ::ConnectionPool::PoolShuttingDownError
65
+ # Another thread closed the pool between our @closed check and checkout.
66
+ # Surface it as our own ClosedError rather than leaking the backing
67
+ # library's exception type.
68
+ raise ClosedError, "pool for #{@origin} is closed"
69
+ end
70
+
71
+ # Immediately close every connection and mark the pool as closed.
72
+ # Any subsequent call to #with will raise ClosedError.
73
+ def close
74
+ return unless @closed.make_true
75
+
76
+ @pool.shutdown do |conn|
77
+ conn&.close
78
+ rescue StandardError
79
+ # Closing a stale connection should not mask the shutdown.
80
+ nil
81
+ end
82
+ end
83
+
84
+ def closed?
85
+ @closed.true?
86
+ end
87
+
88
+ def stats
89
+ available = @pool.available
90
+ {
91
+ origin: @origin,
92
+ size: @size,
93
+ checked_out: @size - available,
94
+ idle: available,
95
+ closed: closed?
96
+ }
97
+ end
98
+
99
+ # Redacted inspect. The default Ruby #inspect would dump @options verbatim,
100
+ # exposing any Authorization header / auth token / SSL material in logs,
101
+ # backtraces, and error-reporting payloads. We list only the option *keys*
102
+ # (never their values) plus the non-sensitive pool state.
103
+ def inspect
104
+ keys = @options.keys
105
+ option_keys = keys.empty? ? 'none' : keys.join(', ')
106
+ "#<#{self.class.name} origin=#{@origin.inspect} size=#{@size} " \
107
+ "timeout=#{@timeout} closed=#{closed?} options=[#{option_keys}]>"
108
+ end
109
+ alias to_s inspect
110
+
111
+ # Belt-and-suspenders for pretty-printers (pp / awesome_print), which call
112
+ # #pretty_print rather than #inspect and would otherwise reach @options.
113
+ def pretty_print(pp)
114
+ pp.text(inspect)
115
+ end
116
+
117
+ private
118
+
119
+ # Freeze the options hash and every nested hash/array/value, so a pool's
120
+ # configuration cannot mutate after creation (and cannot diverge from the
121
+ # options that were hashed into its registry key).
122
+ def deep_freeze(obj)
123
+ case obj
124
+ when Hash then obj.each { |k, v| [k, v].each { |e| deep_freeze(e) } }
125
+ when Array then obj.each { |v| deep_freeze(v) }
126
+ end
127
+ obj.freeze
128
+ end
129
+
130
+ def build_connection
131
+ session = HTTP::Session.new(HTTP::Options.new(**native_options))
132
+ apply_chainable(session)
133
+ end
134
+
135
+ # Directly-mappable HTTP::Options fields, including persistent (= origin).
136
+ # auth is folded into headers as an Authorization header, matching what
137
+ # http.rb's own `auth` chainable does internally.
138
+ def native_options
139
+ opts = { persistent: @origin, headers: headers_with_auth }
140
+ opts[:ssl] = @options[:ssl] if @options[:ssl]
141
+ # TODO: case C — when ssl_context becomes safely keyable, set
142
+ # opts[:ssl_context] = @options[:ssl_context] here. It is currently
143
+ # rejected at the registry keying boundary, so it never reaches this
144
+ # method. See docs/superpowers/specs/2026-06-25-error-handling-design.md.
145
+ opts
146
+ end
147
+
148
+ # Merge auth into the headers hash as an Authorization header. Uses merge
149
+ # (not mutation) so the frozen @options[:headers] is never modified, and
150
+ # `to_s` to mirror http.rb's own `auth` chainable (which stringifies value).
151
+ def headers_with_auth
152
+ headers = @options[:headers] || {}
153
+ return headers unless @options[:auth]
154
+
155
+ headers.merge('Authorization' => @options[:auth].to_s)
156
+ end
157
+
158
+ # timeout/proxy need http.rb's own translation (number/hash -> timeout_class
159
+ # + timeout_options; positional args -> proxy_hash), so they stay chainable.
160
+ # Early-return when neither is set (the common case) to avoid extra
161
+ # HTTP::Session allocations from branch/dup.
162
+ def apply_chainable(session)
163
+ return session unless @options[:timeout] || @options[:proxy]
164
+
165
+ session = session.timeout(@options[:timeout]) if @options[:timeout]
166
+ session = session.via(*@options[:proxy]) if @options[:proxy]
167
+ session
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Require only the concurrent-ruby primitives we use rather than the whole
4
+ # library — keeps load time and footprint down.
5
+ require 'concurrent/atomic/atomic_reference'
6
+ require 'concurrent/map'
7
+ require 'digest'
8
+ require 'uri'
9
+ require_relative 'errors'
10
+ require_relative 'pool'
11
+
12
+ module HttpConnectionPool
13
+ # Global, thread-safe registry that holds one Pool per (origin, options) pair.
14
+ #
15
+ # Pools are keyed by a SHA-256 digest of the canonical origin + options, so
16
+ # two callers targeting the same host with different credentials each get their
17
+ # own isolated pool — no credential confusion, no error. This makes subclassing
18
+ # safe: a subclass that overrides pool_options gets a distinct pool from its
19
+ # parent even when both share the same base_url.
20
+ #
21
+ # The registry itself is a singleton (one instance per process). It is not
22
+ # implemented with the `Singleton` module so it can be replaced in tests.
23
+ # Storage is backed by `Concurrent::Map`, and the singleton slot by
24
+ # `Concurrent::AtomicReference`, so reads are lock-free under contention.
25
+ #
26
+ # Usage:
27
+ # registry = HttpConnectionPool::Registry.instance
28
+ # registry.pool_for('https://api.example.com').with { |conn| conn.get('/status') }
29
+ class Registry
30
+ # Backward-compatible alias — canonical class lives in errors.rb.
31
+ PoolLimitError = HttpConnectionPool::PoolLimitError
32
+
33
+ SUPPORTED_SCHEMES = %w[http https].freeze
34
+
35
+ # Option values that can be canonically serialized into a pool key. Anything
36
+ # else (an SSLContext, a PKey, a proc, an arbitrary object) is rejected by
37
+ # ensure_keyable! rather than risking a silent inspect-based collision.
38
+ # FUTURE (case C): a canonical serializer would let us key these safely —
39
+ # e.g. restore ssl_context: by digesting its real security material — and
40
+ # remove this rejection. See docs/superpowers/specs/2026-06-25-error-handling-design.md.
41
+ KEYABLE_SCALARS = [String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass].freeze
42
+
43
+ @instance_ref = Concurrent::AtomicReference.new(nil)
44
+
45
+ # @return [Registry] the process-wide singleton instance
46
+ def self.instance
47
+ existing = @instance_ref.get
48
+ return existing if existing
49
+
50
+ candidate = new(max_pools: @configured_max_pools)
51
+ @instance_ref.compare_and_set(nil, candidate) ? candidate : @instance_ref.get
52
+ end
53
+
54
+ # Configure the process-wide singleton's pool ceiling. Must be called before
55
+ # the singleton is first used (e.g. in a Rails initializer); raises if the
56
+ # singleton already exists, since max_pools is fixed at construction.
57
+ #
58
+ # @param max_pools [Integer, nil]
59
+ def self.configure(max_pools:)
60
+ raise 'Registry singleton already initialised; call configure earlier' if @instance_ref.get
61
+
62
+ @configured_max_pools = max_pools
63
+ end
64
+
65
+ # Replace the singleton — primarily for testing.
66
+ def self.reset!
67
+ previous = @instance_ref.get_and_set(nil)
68
+ @configured_max_pools = nil
69
+ previous&.close_all
70
+ end
71
+
72
+ # @param max_pools [Integer, nil]
73
+ # Optional ceiling on the number of distinct origins held at once. nil
74
+ # (the default) means unlimited. Set this when origins can be influenced
75
+ # by untrusted input (webhook targets, redirect hosts, user-supplied
76
+ # URLs) to bound memory and file-descriptor use — without it, each new
77
+ # origin retains a pool and its sockets indefinitely.
78
+ def initialize(max_pools: nil)
79
+ @pools = Concurrent::Map.new
80
+ @max_pools = max_pools && Integer(max_pools)
81
+
82
+ raise ArgumentError, 'max_pools must be >= 1' if @max_pools && @max_pools < 1
83
+ end
84
+
85
+ attr_reader :max_pools
86
+
87
+ # Return (or lazily create) a Pool for the given URL's origin + options.
88
+ #
89
+ # Each unique (origin, options) combination gets its own isolated pool, so
90
+ # two callers sharing a host but using different credentials (Authorization
91
+ # headers, auth tokens, etc.) never share connections.
92
+ #
93
+ # @param url [String] any URL whose scheme+host+port will be used as the key
94
+ # @param size [Integer] pool size (ignored if an identical pool already exists)
95
+ # @param timeout [Float] checkout timeout in seconds (ignored if pool already exists)
96
+ # @param options [Hash] HTTP client options forwarded to Pool (headers, auth, ssl, etc.)
97
+ # @return [Pool]
98
+ def pool_for(url, size: Pool::DEFAULT_SIZE, timeout: Pool::DEFAULT_TIMEOUT, **options)
99
+ origin = extract_origin(url)
100
+ key = pool_key(origin, options)
101
+
102
+ loop do
103
+ existing = @pools[key]
104
+ return existing if existing && !existing.closed?
105
+
106
+ # Only new keys count against the cap; reusing/replacing an existing
107
+ # key is always allowed.
108
+ ensure_within_limit!(key)
109
+
110
+ candidate = Pool.new(origin: origin, size: size, timeout: timeout, **options)
111
+ resolved = insert_or_resolve(key, candidate)
112
+ return resolved if resolved
113
+ end
114
+ end
115
+
116
+ # Remove and close the pool that exactly matches the given URL + options.
117
+ # Without options it closes the no-options pool for the origin.
118
+ #
119
+ # @param url [String]
120
+ # @param options [Hash]
121
+ def release(url, **options)
122
+ key = pool_key(extract_origin(url), options)
123
+ pool = @pools.delete(key)
124
+ pool&.close
125
+ end
126
+
127
+ # Close every pool and clear the registry.
128
+ def close_all
129
+ @pools.each_pair do |key, pool|
130
+ @pools.delete_pair(key, pool)
131
+ pool.close
132
+ end
133
+ end
134
+
135
+ # Evict every pool that has already been closed out-of-band (e.g. via
136
+ # Pool#close rather than Registry#release). Dead pools are otherwise only
137
+ # reclaimed when their exact key is requested again, so a long-running
138
+ # process that closes pools directly should call this periodically to free
139
+ # the retained Pool objects (and their option material) and the cap slots
140
+ # they would otherwise hold. Returns the number of pools swept.
141
+ def sweep_closed!
142
+ swept = 0
143
+ @pools.each_pair do |key, pool|
144
+ swept += 1 if pool.closed? && @pools.delete_pair(key, pool)
145
+ end
146
+ swept
147
+ end
148
+
149
+ # @return [Array<Hash>] snapshot of stats for every registered pool
150
+ def stats
151
+ result = []
152
+ @pools.each_pair { |_key, pool| result << pool.stats }
153
+ result
154
+ end
155
+
156
+ # Safe inspect — shows pool count and cap without dumping internal keys or
157
+ # any pool state that might reference credential material.
158
+ def inspect
159
+ limit = @max_pools ? @max_pools.to_s : 'unlimited'
160
+ "#<#{self.class.name} pools=#{@pools.size} max_pools=#{limit}>"
161
+ end
162
+ alias to_s inspect
163
+
164
+ private
165
+
166
+ # Derive a stable, collision-resistant registry key from the canonical
167
+ # origin and options. The key is a SHA-256 hex digest of the origin and a
168
+ # deeply-sorted, canonical representation of the options hash, so:
169
+ # * key ordering within nested hashes does not matter — callers that
170
+ # supply the same headers in different insertion order get the same pool
171
+ # * mixed symbol/string keys are handled safely (sorted by to_s)
172
+ # * the digest itself never appears in user-visible output, so it cannot
173
+ # be used to verify guesses about credential values
174
+ def pool_key(origin, options)
175
+ ensure_keyable!(options)
176
+ Digest::SHA256.hexdigest("#{origin}|#{normalize_options(options).inspect}")
177
+ end
178
+
179
+ # Reject any option value that cannot be canonically serialized for keying.
180
+ # The path is built only from Symbol option names (e.g. :ssl_context) and
181
+ # array indices; any non-Symbol key (e.g. a user-supplied header name, which
182
+ # could itself be sensitive) is rendered as its class, never its content, so
183
+ # the message can never leak credential material.
184
+ def ensure_keyable!(value, path = 'options')
185
+ case value
186
+ when *KEYABLE_SCALARS
187
+ nil
188
+ when Hash
189
+ value.each do |k, v|
190
+ ensure_keyable!(k, "#{path} key")
191
+ ensure_keyable!(v, "#{path}[#{key_label(k)}]")
192
+ end
193
+ when Array
194
+ value.each_with_index { |v, i| ensure_keyable!(v, "#{path}[#{i}]") }
195
+ else
196
+ raise OptionKeyError,
197
+ "option #{path} is a #{value.class} and cannot be used as a pool key; " \
198
+ 'pass SSL material via the `ssl:` hash (e.g. ssl: { ca_file: ... }), or ' \
199
+ 'give each distinct context its own Connectable subclass / explicit pool'
200
+ end
201
+ end
202
+
203
+ # Symbol keys are option names (:headers, :ssl_context) and safe to show;
204
+ # any other key (e.g. a user-supplied String header name) is shown only as
205
+ # its class, so a sensitive value used as a key cannot leak into the message.
206
+ def key_label(key)
207
+ key.is_a?(Symbol) ? key.inspect : "<#{key.class}>"
208
+ end
209
+
210
+ # Recursively sort hash keys so that two logically identical option hashes
211
+ # always produce the same digest regardless of key-insertion order. Arrays
212
+ # and scalar values are passed through as-is.
213
+ #
214
+ # Sort by k.inspect, not k.to_s: a String and a Symbol that stringify to the
215
+ # same value (e.g. 'a' and :a) would otherwise be indistinguishable to the
216
+ # comparator, so their relative order would depend on insertion order and
217
+ # the same logical hash could digest to two different keys. inspect renders
218
+ # them distinctly (":a" vs "\"a\""), giving a total, deterministic order.
219
+ def normalize_options(obj)
220
+ case obj
221
+ when Hash
222
+ obj.each_with_object({}) { |(k, v), h| h[k] = normalize_options(v) }
223
+ .sort_by { |k, _| k.inspect }.to_h
224
+ when Array then obj.map { |v| normalize_options(v) }
225
+ else obj
226
+ end
227
+ end
228
+
229
+ # Atomically insert `candidate`, or resolve what is already there.
230
+ # Returns the Pool to hand back, or nil to signal the caller should retry
231
+ # the loop (a stale closed pool was evicted).
232
+ def insert_or_resolve(key, candidate)
233
+ # compute_if_absent only inserts when no live entry exists.
234
+ winner = @pools.compute_if_absent(key) { candidate }
235
+ return candidate if winner.equal?(candidate)
236
+
237
+ # A stale closed pool is occupying the slot — evict it and retry.
238
+ if winner.closed?
239
+ @pools.delete_pair(key, winner)
240
+ candidate.close
241
+ return nil
242
+ end
243
+
244
+ # Lost the race to another caller with identical options; reuse theirs.
245
+ candidate.close
246
+ winner
247
+ end
248
+
249
+ # Enforce the optional max_pools ceiling before creating a new pool.
250
+ # This is a soft cap: the size check and the insert are not a single
251
+ # atomic step (doing so would require a global lock and defeat the
252
+ # lock-free Concurrent::Map). Under heavy concurrency the count may briefly
253
+ # overshoot by roughly the number of distinct keys racing to be created,
254
+ # but growth stays bounded — which is the point of the DoS backstop.
255
+ #
256
+ # Only *live* pools count toward the cap: a pool closed out-of-band (via
257
+ # Pool#close) is dead weight, not an active connection set, so it must not
258
+ # block creation of a new pool. It will be evicted lazily when its key is
259
+ # re-requested, or eagerly via sweep_closed!.
260
+ def ensure_within_limit!(key)
261
+ return unless @max_pools
262
+ return if @pools.key?(key) || live_pool_count < @max_pools
263
+
264
+ raise PoolLimitError,
265
+ "connection pool limit of #{@max_pools} reached. " \
266
+ 'Release unused pools or raise max_pools.'
267
+ end
268
+
269
+ def live_pool_count
270
+ count = 0
271
+ @pools.each_value { |pool| count += 1 unless pool.closed? }
272
+ count
273
+ end
274
+
275
+ # Normalise a full URL down to its origin (scheme + host + port).
276
+ #
277
+ # @param url [String]
278
+ # @return [String] e.g. "https://api.example.com:443"
279
+ def extract_origin(url)
280
+ uri = parse_url(url)
281
+ raise InvalidURLError, "URL must have a scheme (http/https): #{url}" unless uri.scheme
282
+ raise InvalidURLError, "unsupported scheme: #{uri.scheme}" unless SUPPORTED_SCHEMES.include?(uri.scheme)
283
+ raise InvalidURLError, "URL must have a host: #{url}" if uri.host.nil? || uri.host.empty?
284
+
285
+ # Hostnames are case-insensitive (DNS), so downcase to avoid keying two
286
+ # pools for the same origin written in different cases. URI always
287
+ # populates the default port (80/443) for http/https, so uri.port is
288
+ # reliable here and no fallback table is needed.
289
+ "#{uri.scheme}://#{uri.host.downcase}:#{uri.port}"
290
+ end
291
+
292
+ def parse_url(url)
293
+ URI.parse(url)
294
+ rescue URI::InvalidURIError => e
295
+ raise InvalidURLError, "could not parse URL: #{url} (#{e.message})"
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HttpConnectionPool
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'http_connection_pool/version'
4
+ require_relative 'http_connection_pool/errors'
5
+ require_relative 'http_connection_pool/pool'
6
+ require_relative 'http_connection_pool/registry'
7
+ require_relative 'http_connection_pool/connectable'
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: http_connection_pool
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - bbarberBPL
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.3.7
19
+ - - "~>"
20
+ - !ruby/object:Gem::Version
21
+ version: '1.3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 1.3.7
29
+ - - "~>"
30
+ - !ruby/object:Gem::Version
31
+ version: '1.3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: connection_pool
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.5.5
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 2.5.5
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3'
52
+ - !ruby/object:Gem::Dependency
53
+ name: http
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '6.0'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '6.0'
66
+ description: Provides a singleton connection pool per URL origin using http.rb (httprb),
67
+ with a Connectable mixin for easy integration into service/API client classes.
68
+ executables: []
69
+ extensions: []
70
+ extra_rdoc_files: []
71
+ files:
72
+ - LICENSE
73
+ - README.md
74
+ - lib/http_connection_pool.rb
75
+ - lib/http_connection_pool/connectable.rb
76
+ - lib/http_connection_pool/errors.rb
77
+ - lib/http_connection_pool/pool.rb
78
+ - lib/http_connection_pool/registry.rb
79
+ - lib/http_connection_pool/version.rb
80
+ licenses:
81
+ - MIT
82
+ metadata: {}
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.3.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 4.0.14
98
+ specification_version: 4
99
+ summary: Thread-safe persistent HTTP connection pool for the http.rb gem
100
+ test_files: []