workos 8.0.1 → 8.0.2

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: ca01c2ba88ce0aff45ae8689e584ba67896309cefeda715c18b119094e238a7b
4
- data.tar.gz: 6733400a815cf3e8274e5eda8f95dbf2bfa9ea65da27fcc8b59e027f85b6182c
3
+ metadata.gz: 24ec60a2134602d268907b2a885e04872b8ad5bb7ccff1095abb1a1d7bbb30d7
4
+ data.tar.gz: 1ef26bc5f186054f6949156b127d64ad3b1533c38468e0c919105b2ce7e0dd4c
5
5
  SHA512:
6
- metadata.gz: 79ed8ec1968491abfdb5d884988176fd02d9b3ddd74e777e59d7f0cf47609a3f9f1c45bfd7e1cca94624f1734b7f2a95c8f71da9c27c32f3a843a98cd720a5bf
7
- data.tar.gz: e72fca2d3bf036ee5b134500ba001c7f35db82473edde30c4fcc748e2e492d0450a9d8ce4f1d11c43376672257d7ca51d8ecc0663180e40a3c02d5ab3572d0d4
6
+ metadata.gz: 81b868876a7b9541e043f8ed407b8a0ef1879df9d96ce1163ea737ea037132c80a036a03636165f53e0c96e9a0a52bdc6a8775fc84e953df1be9aa8646e78e34
7
+ data.tar.gz: 9118c02daa8efe8812fb999fd1ac745bc1760f7e6a0613fe34f4272489eff27ebe552e653b2b7dab36de54b8ef06919a81936873af8741e2c7f57e9b4f71898c
@@ -3,7 +3,7 @@ name: Release Please
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - main
6
+ - v8.0.x
7
7
 
8
8
  permissions:
9
9
  contents: write
@@ -24,6 +24,7 @@ jobs:
24
24
  id: release
25
25
  with:
26
26
  token: ${{ steps.generate-token.outputs.token }}
27
+ target-branch: v8.0.x
27
28
 
28
29
  - name: Update Gemfile.lock on release PR
29
30
  if: steps.release.outputs.pr
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "8.0.1"
2
+ ".": "8.0.2"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [8.0.2](https://github.com/workos/workos-ruby/compare/v8.0.1...v8.0.2) (2026-08-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * explicitly evict connections if request does not complete as success ([d79b839](https://github.com/workos/workos-ruby/commit/d79b8390690ce595896d1b6057cf68e5058b98da))
9
+
3
10
  ## [8.0.1](https://github.com/workos/workos-ruby/compare/v8.0.0...v8.0.1) (2026-05-12)
4
11
 
5
12
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- workos (8.0.1)
4
+ workos (8.0.2)
5
5
  jwt (~> 3.1)
6
6
  logger (~> 1.7)
7
7
  zeitwerk (~> 2.6)
@@ -153,7 +153,7 @@ CHECKSUMS
153
153
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
154
154
  webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
155
155
  webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
156
- workos (8.0.1)
156
+ workos (8.0.2)
157
157
  yard (0.9.43) sha256=cf8733a8f0485df2a162927e9b5f182215a61f6d22de096b8f402c726a1c5821
158
158
  yard-markdown (0.7.1) sha256=06c378632dfe7ba053be9ba469eb4701aa0470e36bcf7e5546f353eb90c1bfd1
159
159
  zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
@@ -137,7 +137,20 @@ module WorkOS
137
137
  loggable_path = redact_path(request.path)
138
138
  log(:debug, "request start", method: request.method, path: loggable_path, attempt: attempt + 1)
139
139
  http = connection_for(base, timeout)
140
- response = http.request(request)
140
+ request_completed = false
141
+ begin
142
+ response = http.request(request)
143
+ request_completed = true
144
+ ensure
145
+ # Any exit from #request other than a returned response can leave
146
+ # the socket mid-stream: a connection error the rescue below knows
147
+ # about, one it doesn't (OpenSSL::SSL::SSLError,
148
+ # Net::HTTPBadResponse), or a non-local exit such as an
149
+ # application-level Timeout.timeout or Thread#kill. A half-read
150
+ # socket handed back to the pool desyncs the *next* request on this
151
+ # thread, so drop it here rather than in the rescue.
152
+ evict_connection(base) unless request_completed
153
+ end
141
154
  return response if response.is_a?(Net::HTTPSuccess)
142
155
 
143
156
  if attempt < retries && retryable?(response)
@@ -2,5 +2,5 @@
2
2
 
3
3
  # @oagen-ignore-file
4
4
  module WorkOS
5
- VERSION = "8.0.1"
5
+ VERSION = "8.0.2"
6
6
  end
@@ -82,10 +82,36 @@ class BaseClientTest < Minitest::Test
82
82
  end
83
83
  end
84
84
 
85
+ # A pooled connection that either returns a canned response or raises when
86
+ # driven, so execute_request can be exercised without real TCP+TLS.
87
+ class StubConnection < FakeConnection
88
+ attr_accessor :read_timeout, :open_timeout
89
+
90
+ def initialize(response: nil, error: nil, **kwargs)
91
+ super(**kwargs)
92
+ @response = response
93
+ @error = error
94
+ end
95
+
96
+ def request(_request)
97
+ raise @error if @error
98
+
99
+ @response
100
+ end
101
+ end
102
+
85
103
  def setup
86
104
  @client = WorkOS::BaseClient.new(api_key: "sk_test_123", max_retries: 1)
87
105
  end
88
106
 
107
+ def teardown
108
+ super
109
+ # Close any open connections and clear the fiber-local cache to avoid
110
+ # leaking pooled connections between tests.
111
+ @client.shutdown
112
+ Fiber[:workos_connections] = nil
113
+ end
114
+
89
115
  def test_request_dispatches_known_methods
90
116
  client = RecordingClient.new(api_key: "sk_test_123")
91
117
 
@@ -214,4 +240,141 @@ class BaseClientTest < Minitest::Test
214
240
  redacted = @client.send(:redact_path, "/user_management/magic_auth/magic_secret?token=qs_token")
215
241
  assert_equal "/user_management/magic_auth/[REDACTED]?token=[REDACTED]", redacted
216
242
  end
243
+
244
+ # An exception execute_request's rescue clause doesn't list still leaves the
245
+ # socket mid-stream, so the connection must not survive in the pool for the
246
+ # next request on this thread to pick up.
247
+ def test_unlisted_request_error_evicts_the_pooled_connection
248
+ conn = StubConnection.new(error: Net::HTTPBadResponse.new("wrong version"))
249
+ cache = @client.send(:thread_connections)
250
+ cache["https:api.workos.com:443:30"] = conn
251
+
252
+ assert_raises(Net::HTTPBadResponse) do
253
+ @client.execute_request(request: Net::HTTP::Get.new("/things"))
254
+ end
255
+
256
+ refute cache.key?("https:api.workos.com:443:30"),
257
+ "a connection whose request did not complete must not stay pooled"
258
+ assert conn.finished
259
+ end
260
+
261
+ def test_completed_request_keeps_the_connection_pooled
262
+ conn = StubConnection.new(response: Net::HTTPOK.new("1.1", "200", "OK"))
263
+ cache = @client.send(:thread_connections)
264
+ cache["https:api.workos.com:443:30"] = conn
265
+
266
+ @client.execute_request(request: Net::HTTP::Get.new("/things"))
267
+
268
+ assert cache.key?("https:api.workos.com:443:30")
269
+ refute conn.finished
270
+ end
271
+
272
+ # Raised asynchronously into the worker thread to stand in for a caller-side
273
+ # abort: Timeout.timeout, Thread#kill, a signal handler unwinding the stack.
274
+ # It descends from Exception rather than StandardError so that nothing in
275
+ # execute_request can catch it — the `ensure` is the only cleanup that runs.
276
+ class AbortSignal < Exception; end # standard:disable Lint/InheritException
277
+
278
+ # End-to-end regression test over a real keep-alive socket, for the failure
279
+ # the eviction actually prevents: request one is abandoned mid-flight, the
280
+ # server then writes response one onto that socket, and request two on the
281
+ # same thread reads those stale bytes as if they were its own response.
282
+ #
283
+ # Before the fix, request two sees marker "one" (or a mangled response) off
284
+ # the pooled socket. After it, the abandoned socket is closed and the server
285
+ # accepts a fresh connection for request two.
286
+ def test_aborted_request_does_not_leak_its_response_to_the_next_request
287
+ WebMock.disable!
288
+ server = TCPServer.new("127.0.0.1", 0)
289
+ port = server.addr[1]
290
+ client = WorkOS::BaseClient.new(
291
+ api_key: "sk_test_123",
292
+ base_url: "http://127.0.0.1:#{port}",
293
+ timeout: 5,
294
+ max_retries: 0
295
+ )
296
+
297
+ request_one_read = Queue.new
298
+ release_response_one = Queue.new
299
+ response_one_written = Queue.new
300
+ aborted = Queue.new
301
+ connections = Queue.new
302
+
303
+ server_thread = Thread.new do
304
+ # Connection one: read the request, then hold the response back until
305
+ # the client has been aborted and has unwound.
306
+ first = server.accept
307
+ connections << first
308
+ read_http_request(first)
309
+ request_one_read << true
310
+ release_response_one.pop
311
+ begin
312
+ write_http_response(first, {marker: "one"})
313
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
314
+ # Expected once the fix closes the abandoned socket.
315
+ end
316
+ response_one_written << true
317
+
318
+ # Connection two: a fresh accept, which only completes because the
319
+ # client did not reuse the socket above.
320
+ second = server.accept
321
+ connections << second
322
+ read_http_request(second)
323
+ write_http_response(second, {marker: "two"})
324
+ end
325
+
326
+ worker = Thread.new do
327
+ begin
328
+ client.execute_request(request: Net::HTTP::Get.new("/one"))
329
+ rescue AbortSignal
330
+ # The caller unwinds but the thread survives and goes on to serve more
331
+ # work, the way a Puma or Solid Queue worker does. execute_request's
332
+ # `ensure` has already run by the time this body executes.
333
+ aborted << true
334
+ response_one_written.pop
335
+ end
336
+ response = client.execute_request(request: Net::HTTP::Get.new("/two"))
337
+ JSON.parse(response.body)["marker"]
338
+ end
339
+
340
+ marker = Timeout.timeout(15) do
341
+ request_one_read.pop
342
+ worker.raise(AbortSignal)
343
+ aborted.pop
344
+ release_response_one << true
345
+ worker.value
346
+ end
347
+
348
+ assert_equal "two", marker,
349
+ "request two read the abandoned socket's response instead of its own"
350
+ ensure
351
+ server_thread&.kill
352
+ worker&.kill
353
+ until connections.nil? || connections.empty?
354
+ socket = connections.pop
355
+ socket.close unless socket.closed?
356
+ end
357
+ server&.close
358
+ WebMock.enable!
359
+ end
360
+
361
+ def read_http_request(socket)
362
+ socket.gets # request line
363
+ loop do
364
+ line = socket.gets
365
+ break if line.nil? || line == "\r\n"
366
+ end
367
+ end
368
+
369
+ def write_http_response(socket, body)
370
+ payload = JSON.generate(body)
371
+ socket.write(
372
+ "HTTP/1.1 200 OK\r\n" \
373
+ "Content-Type: application/json\r\n" \
374
+ "Content-Length: #{payload.bytesize}\r\n" \
375
+ "Connection: keep-alive\r\n" \
376
+ "\r\n#{payload}"
377
+ )
378
+ socket.flush
379
+ end
217
380
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workos
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.1
4
+ version: 8.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - WorkOS
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-12 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt