atlas_rb 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 683f9aa507f7cfa025781f231f839bdb39bb44f566e074e8ca304200da86e426
4
- data.tar.gz: e12684f3de4d03e69e1024ab43765a4912f2f550c2ff01e72bc5953da5d55089
3
+ metadata.gz: 8a32e3df39d1a5f9648e47a42b88be5deff1b28dc696f9575a073baac4b9fa58
4
+ data.tar.gz: cb224b216444752165a39f242120e478cbe03c4a2613e36b19665d1897ec59b8
5
5
  SHA512:
6
- metadata.gz: 9b34332d51540262c758c6e40e1c8eab538b3601e22fdde3639e60897e75b6c0cc9483b8a79eed1165f179f07bc2012ee9fd96d218e5dcf589f1d8c0734bd8ab
7
- data.tar.gz: b56c2e3c5cadff71fb0ec53765c3160c27d6d695a0458d0e0e8cb09b65aaf7da483d3d436735cda2d41f82b8dc6227a0a3c262c4a2fc9696d39c9247f5c1e139
6
+ metadata.gz: 43cf01a77168ae4ae78de02bf26403e9c116b63f18d685adc7bf82275124aaa498129ea1a8dad9c4867330b455c236f68f20b05763743be8b4db97ceef0b8324
7
+ data.tar.gz: 6fb09278babb33a465f090e32b0616649bddd4fb043337cbeace8c6c0b7f83ff0cde6710f01eecec69e91d8c65db2d58c67683a7c55496078f090312d7f77317
data/.version CHANGED
@@ -1 +1 @@
1
- 1.14.0
1
+ 1.15.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.0
4
+
5
+ ### Changed — the transport reuses connections instead of opening one per request
6
+
7
+ `FaradayHelper` built a fresh `Faraday.new` for every call, and Faraday's
8
+ default `net_http` adapter wraps each request in its own `Net::HTTP#start`
9
+ block and closes the socket after it. So every Atlas call paid a TCP handshake,
10
+ and under TLS a full TLS handshake on top of that.
11
+
12
+ The three builders now hand back an `AtlasRb::Transport::Proxy` over a shared,
13
+ pooled connection — one per shape (`:json`, `:multipart`, `:system`) and base
14
+ URL — using the `net_http_persistent` adapter. The proxy answers `get`, `post`,
15
+ `patch`, `put` and `delete` like a `Faraday::Connection` and forwards a block to
16
+ the request, so no call site changes.
17
+
18
+ Measured on loopback inside the container, 100 requests per run, self-signed
19
+ ECDSA P-256:
20
+
21
+ | transport | before | after |
22
+ |---|---|---|
23
+ | plain HTTP | 1.21ms/req | 0.75ms/req |
24
+ | TLS | 3.07ms/req | 0.31ms/req |
25
+
26
+ Add a round trip each for TCP and TLS over a real network. The largest win is
27
+ a sequential migration script, where the saving is elapsed time and nothing
28
+ hides it; it also removes the `TIME_WAIT` pressure of a socket per request.
29
+
30
+ Per-request state — the signed assertion, the `Idempotency-Key`, query params —
31
+ now rides on the request rather than being baked into the connection, which is
32
+ what makes a connection safe to share. The pool is process-wide, not
33
+ thread-local, so a fan-out on short-lived threads reuses it.
34
+
35
+ Two knobs, both optional:
36
+
37
+ ```ruby
38
+ AtlasRb.configure do |config|
39
+ config.connection_pool_size = 16 # sockets per Atlas host
40
+ config.connection_max_requests = nil # cap only if a proxy in front caps it
41
+ end
42
+ ```
43
+
44
+ `AtlasRb::Transport.reset_connections!` closes every pooled socket and drops the
45
+ cached connections. A suite that boots and tears down a real server should call
46
+ it between examples.
47
+
48
+ ### Fixed — the signing key is parsed once, not per request
49
+
50
+ `assertion_signing_key` is configured as a callable returning a PEM, and
51
+ `OpenSSL::PKey.read` ran on every request: 0.367ms against the 0.072ms ES256
52
+ signature it exists to produce. The parse is now cached and re-run only when the
53
+ PEM changes.
54
+
3
55
  ## 1.14.0
4
56
 
5
57
  ### Added — `Blob.find_many_versions`, the batch version-history read
data/Gemfile.lock CHANGED
@@ -1,10 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.14.0)
4
+ atlas_rb (1.15.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
8
+ faraday-net_http_persistent (~> 2.3)
8
9
  hashie (~> 5.0)
9
10
  jwt (~> 2.7)
10
11
 
@@ -12,6 +13,7 @@ GEM
12
13
  remote: https://rubygems.org/
13
14
  specs:
14
15
  base64 (0.3.0)
16
+ connection_pool (2.5.5)
15
17
  diff-lcs (1.6.1)
16
18
  faraday (2.14.3)
17
19
  faraday-net_http (>= 2.0, < 3.5)
@@ -23,6 +25,9 @@ GEM
23
25
  multipart-post (~> 2.0)
24
26
  faraday-net_http (3.4.4)
25
27
  net-http (~> 0.5)
28
+ faraday-net_http_persistent (2.3.1)
29
+ faraday (~> 2.5)
30
+ net-http-persistent (>= 4.0.4, < 5)
26
31
  hashie (5.1.0)
27
32
  logger
28
33
  json (2.21.2)
@@ -32,6 +37,8 @@ GEM
32
37
  multipart-post (2.4.1)
33
38
  net-http (0.9.1)
34
39
  uri (>= 0.11.1)
40
+ net-http-persistent (4.0.8)
41
+ connection_pool (>= 2.2.4, < 4)
35
42
  rake (13.2.1)
36
43
  rspec (3.13.0)
37
44
  rspec-core (~> 3.13.0)
data/README.md CHANGED
@@ -156,6 +156,42 @@ atlas_system_token: <token-Atlas-side-recognises-as-:system>
156
156
  The system NUID itself is hardcoded as `AtlasRb::System::NUID =
157
157
  "000000000"`, matching Atlas's seeded `:system` fixture row.
158
158
 
159
+ ### Connection reuse
160
+
161
+ The gem keeps one pooled connection per shape (`:json`, `:multipart`,
162
+ `:system`) and base URL, so a run of Atlas calls reuses its sockets rather than
163
+ opening one per request. Over plain HTTP that saves a TCP handshake per call;
164
+ under TLS it saves the TLS handshake too, which is where the cost actually
165
+ sits — on loopback, 3.07ms per request against 0.31ms. Over a real network add
166
+ a round trip each for TCP and TLS.
167
+
168
+ Nothing is required to get this. The pool is process-wide rather than
169
+ thread-local, so a host that fans requests out on short-lived threads reuses it
170
+ as well.
171
+
172
+ Two knobs, both optional:
173
+
174
+ ```ruby
175
+ AtlasRb.configure do |config|
176
+ # Sockets kept open per Atlas host. Size it to your own concurrency — a host
177
+ # that fans out four reads per request thread wants four times its thread
178
+ # count, plus headroom. Defaults to 16.
179
+ config.connection_pool_size = 16
180
+
181
+ # Requests to send on one socket before replacing it. Leave nil against Puma.
182
+ # Set it when something between you and Puma caps requests per connection,
183
+ # because the request after the cap fails with ECONNRESET.
184
+ config.connection_max_requests = nil
185
+ end
186
+ ```
187
+
188
+ A test suite that boots and tears down a real Atlas server should close the
189
+ pool between examples, or a socket from one example stays open into the next:
190
+
191
+ ```ruby
192
+ config.after(:each, :atlas_rb_server) { AtlasRb::Transport.reset_connections! }
193
+ ```
194
+
159
195
  ## Resource hierarchy
160
196
 
161
197
  ```
@@ -76,5 +76,28 @@ module AtlasRb
76
76
  # Atlas selects the matching public key. Value or callable. Required when
77
77
  # {#assertion_signing_key} is set.
78
78
  attr_accessor :assertion_signing_kid
79
+
80
+ # Sockets the transport keeps open per Atlas host. Size it to the host's
81
+ # own concurrency — a consumer that fans out N reads per request thread
82
+ # wants N times its thread count, plus headroom — rather than to the
83
+ # `net-http-persistent` default of 256, which is a file-descriptor budget
84
+ # rather than a considered number. `nil` takes
85
+ # {AtlasRb::Transport::DEFAULT_POOL_SIZE}.
86
+ #
87
+ # Read when a connection is first built, so set it before the first Atlas
88
+ # call; changing it later has no effect until
89
+ # {AtlasRb::Transport.reset_connections!}.
90
+ #
91
+ # @return [Integer, nil]
92
+ attr_accessor :connection_pool_size
93
+
94
+ # Requests to send on one pooled socket before replacing it. `nil` (the
95
+ # default) means no cap, which is what a direct connection to Puma wants.
96
+ # Set it when something between the client and Puma caps requests per
97
+ # connection, because the request after that cap fails with `ECONNRESET`
98
+ # rather than reconnecting.
99
+ #
100
+ # @return [Integer, nil]
101
+ attr_accessor :connection_max_requests
79
102
  end
80
103
  end
@@ -51,6 +51,16 @@ module AtlasRb
51
51
  # attached the emit degrades to a listener lookup + `yield`, so it is safe to
52
52
  # leave in the stack permanently — opt-in lives entirely on the consumer side.
53
53
  #
54
+ # ## Connection reuse
55
+ #
56
+ # The three builders do not return a `Faraday::Connection`. They return an
57
+ # {AtlasRb::Transport::Proxy} over a shared, cached connection, so Atlas calls
58
+ # reuse sockets instead of paying a TCP — and under TLS a full TLS —
59
+ # handshake each time. The proxy answers the same verbs and forwards a block,
60
+ # so call sites are unchanged. See {AtlasRb::Transport} for why the
61
+ # connection has to be cached for keep-alive to happen at all, and
62
+ # {AtlasRb::Transport.reset_connections!} for the test-suite teardown hook.
63
+ #
54
64
  # The module is mixed in via `extend`, so its methods become class methods on
55
65
  # the host (e.g. `AtlasRb::Work.connection({})`).
56
66
  module FaradayHelper
@@ -91,8 +101,10 @@ module AtlasRb
91
101
  # signs when it can but sends no `Authorization` header otherwise, for the
92
102
  # handful of endpoints Atlas serves with auth skipped (currently only
93
103
  # `GET /reset`).
94
- # @return [Faraday::Connection] a connection that follows redirects and
95
- # uses Faraday's default adapter.
104
+ # @return [AtlasRb::Transport::Proxy] a per-request view onto the shared
105
+ # JSON connection, which follows redirects and pools its sockets. It
106
+ # answers `get` / `post` / `patch` / `put` / `delete` like a
107
+ # `Faraday::Connection` and forwards a block to the request.
96
108
  #
97
109
  # @example Fetching a community
98
110
  # AtlasRb::Community.connection({}).get('/communities/abc123')
@@ -101,22 +113,23 @@ module AtlasRb
101
113
  .merge("Content-Type" => "application/json")
102
114
  headers["Idempotency-Key"] = idempotency_key if idempotency_key
103
115
 
104
- Faraday.new(
105
- url: ENV.fetch("ATLAS_URL", nil),
106
- params: params,
107
- headers: headers
108
- ) do |f|
109
- instrument(f)
110
- f.use AtlasRb::Middleware::RaiseOnStaleResource
111
- f.use AtlasRb::Middleware::RaiseOnResourceError
112
- # Path-independent, unlike the pair above: a maintenance window refuses
113
- # writes on EVERY path, and a 503 reaches neither of them. Registered on
114
- # all three connection builders — a write that slips past it silently
115
- # unwraps nil and reports success.
116
- f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
117
- f.response :follow_redirects
118
- f.adapter Faraday.default_adapter
116
+ url = ENV.fetch("ATLAS_URL", nil)
117
+ conn = AtlasRb::Transport.connection_for([:json, url]) do
118
+ Faraday.new(url: url) do |f|
119
+ instrument(f)
120
+ f.use AtlasRb::Middleware::RaiseOnStaleResource
121
+ f.use AtlasRb::Middleware::RaiseOnResourceError
122
+ # Path-independent, unlike the pair above: a maintenance window refuses
123
+ # writes on EVERY path, and a 503 reaches neither of them. Registered on
124
+ # all three connection builders a write that slips past it silently
125
+ # unwraps nil and reports success.
126
+ f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
127
+ f.response :follow_redirects
128
+ persistent_adapter(f)
129
+ end
119
130
  end
131
+
132
+ AtlasRb::Transport::Proxy.new(conn, headers, params)
120
133
  end
121
134
 
122
135
  # Build a multipart Faraday connection used for binary and XML uploads.
@@ -134,7 +147,8 @@ module AtlasRb
134
147
  # @param idempotency_key [String, nil] optional UUID to send in the
135
148
  # `Idempotency-Key` header. See {#connection} for semantics; the
136
149
  # `POST /files` (Blob) create flow uses this transport.
137
- # @return [Faraday::Connection] a multipart-aware connection.
150
+ # @return [AtlasRb::Transport::Proxy] a per-request view onto the shared
151
+ # multipart connection.
138
152
  #
139
153
  # @example Posting a binary blob
140
154
  # payload = {
@@ -148,24 +162,27 @@ module AtlasRb
148
162
  headers = auth_headers(nuid, on_behalf_of, account: account)
149
163
  headers["Idempotency-Key"] = idempotency_key if idempotency_key
150
164
 
151
- Faraday.new(
152
- url: ENV.fetch("ATLAS_URL", nil),
153
- headers: headers
154
- ) do |f|
155
- instrument(f)
156
- f.use AtlasRb::Middleware::RaiseOnStaleResource
157
- # Translate Atlas's verify-on-ingest 422 (fixity_mismatch /
158
- # unsupported_digest_algorithm) into a typed FixityMismatchError
159
- # the JSON-connection path already carries this; uploads need it too.
160
- f.use AtlasRb::Middleware::RaiseOnResourceError
161
- # Path-independent, unlike the pair above: a maintenance window refuses
162
- # writes on EVERY path, and a 503 reaches neither of them. Registered on
163
- # all three connection builders — a write that slips past it silently
164
- # unwraps nil and reports success.
165
- f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
166
- f.request :multipart
167
- f.request :url_encoded
165
+ url = ENV.fetch("ATLAS_URL", nil)
166
+ conn = AtlasRb::Transport.connection_for([:multipart, url]) do
167
+ Faraday.new(url: url) do |f|
168
+ instrument(f)
169
+ f.use AtlasRb::Middleware::RaiseOnStaleResource
170
+ # Translate Atlas's verify-on-ingest 422 (fixity_mismatch /
171
+ # unsupported_digest_algorithm) into a typed FixityMismatchError
172
+ # the JSON-connection path already carries this; uploads need it too.
173
+ f.use AtlasRb::Middleware::RaiseOnResourceError
174
+ # Path-independent, unlike the pair above: a maintenance window refuses
175
+ # writes on EVERY path, and a 503 reaches neither of them. Registered on
176
+ # all three connection builders a write that slips past it silently
177
+ # unwraps nil and reports success.
178
+ f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
179
+ f.request :multipart
180
+ f.request :url_encoded
181
+ persistent_adapter(f)
182
+ end
168
183
  end
184
+
185
+ AtlasRb::Transport::Proxy.new(conn, headers)
169
186
  end
170
187
 
171
188
  # Build a streaming multipart FilePart for `blob_path`, run the request
@@ -176,10 +193,12 @@ module AtlasRb
176
193
  # exhausts FDs across a TB migration of millions of files.
177
194
  #
178
195
  # Streaming/memory: faraday-multipart wraps the part in a streaming
179
- # CompositeReadIO and the default net_http adapter sends it via
196
+ # CompositeReadIO and the pinned net_http_persistent adapter sends it via
180
197
  # `request.body_stream` (Content-Length known), so a multi-GB file uploads
181
- # without being buffered into a String in memory. (Swapping the host app's
182
- # default Faraday adapter to a buffering one would regress this.)
198
+ # without being buffered into a String in memory. The adapter is pinned by
199
+ # {#persistent_adapter} rather than read from `Faraday.default_adapter`, so
200
+ # a host app's own default cannot swap a buffering adapter in underneath
201
+ # this.
183
202
  #
184
203
  # @param blob_path [String] path to the binary on disk.
185
204
  # @yieldparam part [Faraday::Multipart::FilePart] the streaming part.
@@ -208,7 +227,8 @@ module AtlasRb
208
227
  # @param params [Hash] query-string / body params.
209
228
  # @param on_behalf_of [String, nil] optional NUID sent as a plain (not
210
229
  # signed) `On-Behalf-Of` header — this path is already backend-only.
211
- # @return [Faraday::Connection] a system-authenticated connection.
230
+ # @return [AtlasRb::Transport::Proxy] a per-request view onto the shared
231
+ # system-authenticated connection.
212
232
  # @raise [RuntimeError] if the credential is not configured.
213
233
  # @raise [NameError] if `Rails` is not loaded (the gem assumes a
214
234
  # Rails host for system-path calls).
@@ -223,28 +243,42 @@ module AtlasRb
223
243
  }
224
244
  headers["On-Behalf-Of"] = "NUID #{on_behalf_of}" if on_behalf_of
225
245
 
226
- Faraday.new(
227
- url: ENV.fetch("ATLAS_URL", nil),
228
- params: params,
229
- headers: headers
230
- ) do |f|
231
- instrument(f)
232
- # Narrowly path-scoped (see each class) — a no-op for System::User /
233
- # System::Token, and gives System::Work the same typed errors as #connection.
234
- f.use AtlasRb::Middleware::RaiseOnStaleResource
235
- f.use AtlasRb::Middleware::RaiseOnResourceError
236
- # Path-independent, unlike the pair above: a maintenance window refuses
237
- # writes on EVERY path, and a 503 reaches neither of them. Registered on
238
- # all three connection builders — a write that slips past it silently
239
- # unwraps nil and reports success.
240
- f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
241
- f.response :follow_redirects
242
- f.adapter Faraday.default_adapter
246
+ url = ENV.fetch("ATLAS_URL", nil)
247
+ conn = AtlasRb::Transport.connection_for([:system, url]) do
248
+ Faraday.new(url: url) do |f|
249
+ instrument(f)
250
+ # Narrowly path-scoped (see each class) a no-op for System::User /
251
+ # System::Token, and gives System::Work the same typed errors as #connection.
252
+ f.use AtlasRb::Middleware::RaiseOnStaleResource
253
+ f.use AtlasRb::Middleware::RaiseOnResourceError
254
+ # Path-independent, unlike the pair above: a maintenance window refuses
255
+ # writes on EVERY path, and a 503 reaches neither of them. Registered on
256
+ # all three connection builders a write that slips past it silently
257
+ # unwraps nil and reports success.
258
+ f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
259
+ f.response :follow_redirects
260
+ persistent_adapter(f)
261
+ end
243
262
  end
263
+
264
+ AtlasRb::Transport::Proxy.new(conn, headers, params)
244
265
  end
245
266
 
246
267
  private
247
268
 
269
+ # Pin the pooling adapter on every builder. Pinned rather than taking
270
+ # `Faraday.default_adapter`, because the saving depends on which adapter is
271
+ # in use: the default `net_http` wraps each request in its own
272
+ # `Net::HTTP#start` block and closes the socket after it, by design, so no
273
+ # amount of connection caching reuses anything under it. Pinning also means
274
+ # the streaming upload path no longer depends on what the host app happens
275
+ # to have set as its Faraday default.
276
+ def persistent_adapter(builder)
277
+ builder.adapter :net_http_persistent, pool_size: AtlasRb::Transport.pool_size do |http|
278
+ AtlasRb::Transport.configure_persistent(http)
279
+ end
280
+ end
281
+
248
282
  # Register Faraday's instrumentation middleware as the OUTERMOST handler so
249
283
  # each logical call emits exactly one {INSTRUMENTATION_EVENT} — a redirect
250
284
  # hop (e.g. the `/resources/:noid` resolver) is bracketed with the request
@@ -318,12 +352,16 @@ module AtlasRb
318
352
 
319
353
  # Resolve the configured signing key to an OpenSSL::PKey, or nil if signing
320
354
  # is not configured. Accepts a callable (resolved per request), a PEM
321
- # string (parsed), or an already-built key.
355
+ # string, or an already-built key. A PEM is parsed through
356
+ # {AtlasRb::Transport.parsed_key}, because parsing costs several times the
357
+ # signature it exists to produce and the key rarely changes.
322
358
  def assertion_signing_key
323
359
  raw = config_value(AtlasRb.config.assertion_signing_key)
324
360
  return nil if raw.nil?
325
361
 
326
- raw.is_a?(OpenSSL::PKey::PKey) ? raw : OpenSSL::PKey.read(raw)
362
+ return raw if raw.is_a?(OpenSSL::PKey::PKey)
363
+
364
+ AtlasRb::Transport.parsed_key(raw)
327
365
  end
328
366
 
329
367
  def assertion_signing_kid
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ # Process-wide cache of Faraday connections, so Atlas calls reuse sockets
5
+ # instead of paying a TCP (and, under TLS, a full TLS) handshake per request.
6
+ #
7
+ # ## Why a cache is needed at all
8
+ #
9
+ # Keep-alive is what saves the handshake, and keep-alive needs two things
10
+ # that a per-request `Faraday.new` cannot give it. The pool lives on the
11
+ # adapter instance, and Faraday builds a new adapter for every connection —
12
+ # so a connection built per request gets a fresh, empty pool and reuses
13
+ # nothing. The socket has to outlive the request, which means the connection
14
+ # holding it has to as well.
15
+ #
16
+ # The cache is keyed on connection *shape* (`:json`, `:multipart`,
17
+ # `:system`) and base URL, because those are the only things that vary at
18
+ # build time now — per-request state (the signed assertion, the
19
+ # `Idempotency-Key`, the query params) rides on {Proxy} instead.
20
+ #
21
+ # ## Why not a connection per thread
22
+ #
23
+ # A thread-local connection is the obvious cheap version and it is wrong for
24
+ # this consumer: Cerberus fans out its page reads on short-lived threads, so
25
+ # a thread-local pool dies with the thread that built it and the fan-out —
26
+ # the calls that most want reuse — reuses nothing. `net-http-persistent`
27
+ # keeps a shared `ConnectionPool` and uses `Thread.current` only to track
28
+ # re-entrant checkouts, so a socket checked in by a dying thread goes back to
29
+ # the shared stack.
30
+ #
31
+ # ## Thread safety
32
+ #
33
+ # A Faraday connection is safe for concurrent requests as long as nothing
34
+ # mutates it after it is built. Everything here respects that: the cached
35
+ # connection is built once under {MUTEX} (with its middleware stack forced
36
+ # so two threads cannot race to build two adapters, and therefore two pools),
37
+ # and per-request headers and params are set on the request, never assigned
38
+ # onto the shared connection.
39
+ module Transport
40
+ # Sockets kept per host. Sized for the consumer's shape rather than left at
41
+ # the `net-http-persistent` default (256, or a quarter of the open-file
42
+ # limit): Cerberus draws up to four concurrent reads per Puma thread, so
43
+ # the useful number is that fan-out times the thread count, plus headroom.
44
+ DEFAULT_POOL_SIZE = 16
45
+
46
+ MUTEX = Mutex.new
47
+ # Separate from MUTEX so the key cache can never deadlock against a
48
+ # connection build that needs a signed header.
49
+ KEY_MUTEX = Mutex.new
50
+ private_constant :MUTEX, :KEY_MUTEX
51
+
52
+ # Per-request view onto a shared, cached Faraday connection.
53
+ #
54
+ # It exists because the state a request needs — the signed assertion (30s
55
+ # TTL), the `Idempotency-Key`, the query params — used to be baked into the
56
+ # connection at build time, and a connection that outlives the request
57
+ # cannot carry any of it. The proxy holds that state and applies it to each
58
+ # request instead.
59
+ #
60
+ # The caller-supplied block is forwarded, not swallowed: call sites that
61
+ # need per-request control already use Faraday's block form (a `Range`
62
+ # header, an `on_data` streaming handler), and those must keep working.
63
+ # The block runs last so a call site can override anything set here.
64
+ class Proxy
65
+ # Per-request headers and params, exposed so a caller (and the gem's own
66
+ # transport specs) can read what a request will send without making one.
67
+ attr_reader :headers, :params
68
+
69
+ # The shared connection behind this proxy, for reading its middleware
70
+ # stack or adapter. Do not mutate it — it is shared across every request
71
+ # of this shape, including ones in flight on other threads.
72
+ attr_reader :connection
73
+
74
+ # @param connection [Faraday::Connection] the shared, cached connection.
75
+ # @param headers [Hash] per-request headers (auth, content type,
76
+ # idempotency key).
77
+ # @param params [Hash] per-request query/body params.
78
+ def initialize(connection, headers, params = {})
79
+ @connection = connection
80
+ @headers = headers
81
+ @params = params || {}
82
+ end
83
+
84
+ # @!method get(path, params = nil, headers = nil, &block)
85
+ # @!method delete(path, params = nil, headers = nil, &block)
86
+ # Bodyless verbs — a second positional argument is query params, matching
87
+ # `Faraday::Connection`.
88
+ %i[get delete].each do |verb|
89
+ define_method(verb) do |path, params = nil, headers = nil, &block|
90
+ run(verb, path, nil, headers, params, &block)
91
+ end
92
+ end
93
+
94
+ # @!method post(path, body = nil, headers = nil, &block)
95
+ # @!method patch(path, body = nil, headers = nil, &block)
96
+ # @!method put(path, body = nil, headers = nil, &block)
97
+ # Body-carrying verbs — a second positional argument is the body,
98
+ # matching `Faraday::Connection`.
99
+ %i[post patch put].each do |verb|
100
+ define_method(verb) do |path, body = nil, headers = nil, &block|
101
+ run(verb, path, body, headers, nil, &block)
102
+ end
103
+ end
104
+
105
+ # Escape hatch for a verb the five above don't cover, with the same
106
+ # per-request header/param application.
107
+ #
108
+ # @return [Faraday::Response]
109
+ def run_request(method, path, body, headers, &block)
110
+ run(method, path, body, headers, nil, &block)
111
+ end
112
+
113
+ private
114
+
115
+ def run(method, path, body, headers, params, &block)
116
+ @connection.run_request(method, path, body, headers) do |req|
117
+ req.params.update(@params) unless @params.empty?
118
+ req.params.update(params) if params
119
+ req.headers.update(@headers)
120
+ block&.call(req)
121
+ end
122
+ end
123
+ end
124
+
125
+ class << self
126
+ # Fetch the cached connection for `key`, building it from the block on
127
+ # first use.
128
+ #
129
+ # @param key [Array] connection shape and base URL.
130
+ # @yieldreturn [Faraday::Connection] a freshly built connection.
131
+ # @return [Faraday::Connection] the shared connection for `key`.
132
+ def connection_for(key)
133
+ MUTEX.synchronize do
134
+ connections[key] ||= begin
135
+ conn = yield
136
+ # Force the middleware stack (and with it the adapter, and with it
137
+ # the pool) while still holding the lock. Faraday memoizes the app
138
+ # lazily on first request, and two threads racing that memoization
139
+ # would each build an adapter — two pools, no reuse.
140
+ conn.builder.app
141
+ conn
142
+ end
143
+ end
144
+ end
145
+
146
+ # Apply the pool settings a host has configured to a
147
+ # `Net::HTTP::Persistent`. Called by the adapter's config block on every
148
+ # request, so it must stay assignment-only and cheap.
149
+ #
150
+ # `max_retries` is restored to 1 here because the adapter zeroes it, and
151
+ # zero is the wrong default for a pooled socket: a server that closed an
152
+ # idle connection produces an error on the next write, and one retry is
153
+ # what turns that into a reconnect instead of a caller-visible failure.
154
+ # `Net::HTTP` gates its retry on `IDEMPOTENT_METHODS_`, so the retry-safe
155
+ # creates (`POST /works`, `/file_sets`, `/files`) are never replayed.
156
+ #
157
+ # @param http [Net::HTTP::Persistent]
158
+ # @return [void]
159
+ def configure_persistent(http)
160
+ http.max_retries = 1
161
+ http.max_requests = AtlasRb.config.connection_max_requests
162
+ end
163
+
164
+ # Pool size for a newly built adapter.
165
+ #
166
+ # @return [Integer]
167
+ def pool_size
168
+ AtlasRb.config.connection_pool_size || DEFAULT_POOL_SIZE
169
+ end
170
+
171
+ # Parse a PEM into an `OpenSSL::PKey`, reusing the last parse.
172
+ #
173
+ # Parsing the key costs roughly five times the ES256 signature it exists
174
+ # to produce, and the signing key is configured as a callable returning a
175
+ # PEM, so it was being reparsed on every request. One entry is enough —
176
+ # the cache is a rotation check, not a store, so a rotated key parses
177
+ # once and the old one is dropped rather than accumulating.
178
+ #
179
+ # @param pem [String] the PEM-encoded key.
180
+ # @return [OpenSSL::PKey::PKey]
181
+ def parsed_key(pem)
182
+ KEY_MUTEX.synchronize do
183
+ if @parsed_pem != pem
184
+ @parsed_pem = pem
185
+ @parsed_key = OpenSSL::PKey.read(pem)
186
+ end
187
+ @parsed_key
188
+ end
189
+ end
190
+
191
+ # Close every pooled socket and drop the cached connections.
192
+ #
193
+ # Pooled sockets outliving a test example are a new source of
194
+ # cross-example coupling, so a suite that boots and tears down a real
195
+ # server (Atlas's `:atlas_rb_server` layer) should call this in its
196
+ # teardown. Shutting a `net-http-persistent` pool down makes it refuse
197
+ # later checkouts, which is why the cached connections are dropped in the
198
+ # same breath — the next call rebuilds both.
199
+ #
200
+ # @return [void]
201
+ def reset_connections!
202
+ MUTEX.synchronize do
203
+ connections.each_value do |conn|
204
+ conn.close
205
+ rescue StandardError
206
+ # A pool already shut down, or a socket already gone, has nothing
207
+ # left to release. The connection is being discarded either way.
208
+ nil
209
+ end
210
+ connections.clear
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ def connections
217
+ @connections ||= {}
218
+ end
219
+ end
220
+ end
221
+ end
data/lib/atlas_rb.rb CHANGED
@@ -3,12 +3,16 @@
3
3
  require "faraday"
4
4
  require "faraday/multipart"
5
5
  require "faraday/follow_redirects"
6
+ # Pooling adapter. Required here, not lazily, because the transport pins it on
7
+ # every connection — see AtlasRb::FaradayHelper#persistent_adapter.
8
+ require "faraday/net_http_persistent"
6
9
  require "jwt"
7
10
  require "openssl"
8
11
  require "securerandom"
9
12
  require_relative "atlas_rb/version"
10
13
  require_relative "atlas_rb/errors"
11
14
  require_relative "atlas_rb/configuration"
15
+ require_relative "atlas_rb/transport"
12
16
  require_relative "atlas_rb/middleware/raise_on_stale_resource"
13
17
  require_relative "atlas_rb/middleware/raise_on_resource_error"
14
18
  require_relative "atlas_rb/middleware/raise_on_read_only_mode"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-26 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: 0.3.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: faraday-net_http_persistent
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.3'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.3'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: hashie
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -155,6 +169,7 @@ files:
155
169
  - lib/atlas_rb/system/token.rb
156
170
  - lib/atlas_rb/system/user.rb
157
171
  - lib/atlas_rb/system/work.rb
172
+ - lib/atlas_rb/transport.rb
158
173
  - lib/atlas_rb/user.rb
159
174
  - lib/atlas_rb/version.rb
160
175
  - lib/atlas_rb/work.rb