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 +7 -0
- data/LICENSE +21 -0
- data/README.md +490 -0
- data/lib/http_connection_pool/connectable.rb +175 -0
- data/lib/http_connection_pool/errors.rb +30 -0
- data/lib/http_connection_pool/pool.rb +170 -0
- data/lib/http_connection_pool/registry.rb +298 -0
- data/lib/http_connection_pool/version.rb +5 -0
- data/lib/http_connection_pool.rb +7 -0
- metadata +100 -0
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,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: []
|