Skip to content

Commit 3d08ced

Browse files
Merge commit from fork
Co-authored-by: Alida W <alida.weatherbee@workos.com>
1 parent a12ff5c commit 3d08ced

2 files changed

Lines changed: 169 additions & 1 deletion

File tree

lib/workos/base_client.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,20 @@ def execute_request(request:, request_options: nil)
137137
loggable_path = redact_path(request.path)
138138
log(:debug, "request start", method: request.method, path: loggable_path, attempt: attempt + 1)
139139
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
141154
return response if response.is_a?(Net::HTTPSuccess)
142155

143156
if attempt < retries && retryable?(response)

test/workos/test_base_client.rb

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,24 @@ def finish
8282
end
8383
end
8484

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+
85103
def setup
86104
@client = WorkOS::BaseClient.new(api_key: "sk_test_123", max_retries: 1)
87105
end
@@ -206,6 +224,143 @@ def test_evict_connection_removes_matching_pooled_connections
206224
refute keep.finished
207225
end
208226

227+
# An exception execute_request's rescue clause doesn't list still leaves the
228+
# socket mid-stream, so the connection must not survive in the pool for the
229+
# next request on this thread to pick up.
230+
def test_unlisted_request_error_evicts_the_pooled_connection
231+
conn = StubConnection.new(error: Net::HTTPBadResponse.new("wrong version"))
232+
cache = @client.send(:thread_connections)
233+
cache["https:api.workos.com:443:30"] = conn
234+
235+
assert_raises(Net::HTTPBadResponse) do
236+
@client.execute_request(request: Net::HTTP::Get.new("/things"))
237+
end
238+
239+
refute cache.key?("https:api.workos.com:443:30"),
240+
"a connection whose request did not complete must not stay pooled"
241+
assert conn.finished
242+
end
243+
244+
def test_completed_request_keeps_the_connection_pooled
245+
conn = StubConnection.new(response: Net::HTTPOK.new("1.1", "200", "OK"))
246+
cache = @client.send(:thread_connections)
247+
cache["https:api.workos.com:443:30"] = conn
248+
249+
@client.execute_request(request: Net::HTTP::Get.new("/things"))
250+
251+
assert cache.key?("https:api.workos.com:443:30")
252+
refute conn.finished
253+
end
254+
255+
# Raised asynchronously into the worker thread to stand in for a caller-side
256+
# abort: Timeout.timeout, Thread#kill, a signal handler unwinding the stack.
257+
# It descends from Exception rather than StandardError so that nothing in
258+
# execute_request can catch it — the `ensure` is the only cleanup that runs.
259+
class AbortSignal < Exception; end # standard:disable Lint/InheritException
260+
261+
# End-to-end regression test over a real keep-alive socket, for the failure
262+
# the eviction actually prevents: request one is abandoned mid-flight, the
263+
# server then writes response one onto that socket, and request two on the
264+
# same thread reads those stale bytes as if they were its own response.
265+
#
266+
# Before the fix, request two sees marker "one" (or a mangled response) off
267+
# the pooled socket. After it, the abandoned socket is closed and the server
268+
# accepts a fresh connection for request two.
269+
def test_aborted_request_does_not_leak_its_response_to_the_next_request
270+
WebMock.disable!
271+
server = TCPServer.new("127.0.0.1", 0)
272+
port = server.addr[1]
273+
client = WorkOS::BaseClient.new(
274+
api_key: "sk_test_123",
275+
base_url: "http://127.0.0.1:#{port}",
276+
timeout: 5,
277+
max_retries: 0
278+
)
279+
280+
request_one_read = Queue.new
281+
release_response_one = Queue.new
282+
response_one_written = Queue.new
283+
aborted = Queue.new
284+
connections = Queue.new
285+
286+
server_thread = Thread.new do
287+
# Connection one: read the request, then hold the response back until
288+
# the client has been aborted and has unwound.
289+
first = server.accept
290+
connections << first
291+
read_http_request(first)
292+
request_one_read << true
293+
release_response_one.pop
294+
begin
295+
write_http_response(first, {marker: "one"})
296+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
297+
# Expected once the fix closes the abandoned socket.
298+
end
299+
response_one_written << true
300+
301+
# Connection two: a fresh accept, which only completes because the
302+
# client did not reuse the socket above.
303+
second = server.accept
304+
connections << second
305+
read_http_request(second)
306+
write_http_response(second, {marker: "two"})
307+
end
308+
309+
worker = Thread.new do
310+
begin
311+
client.execute_request(request: Net::HTTP::Get.new("/one"))
312+
rescue AbortSignal
313+
# The caller unwinds but the thread survives and goes on to serve more
314+
# work, the way a Puma or Solid Queue worker does. execute_request's
315+
# `ensure` has already run by the time this body executes.
316+
aborted << true
317+
response_one_written.pop
318+
end
319+
response = client.execute_request(request: Net::HTTP::Get.new("/two"))
320+
JSON.parse(response.body)["marker"]
321+
end
322+
323+
marker = Timeout.timeout(15) do
324+
request_one_read.pop
325+
worker.raise(AbortSignal)
326+
aborted.pop
327+
release_response_one << true
328+
worker.value
329+
end
330+
331+
assert_equal "two", marker,
332+
"request two read the abandoned socket's response instead of its own"
333+
ensure
334+
server_thread&.kill
335+
worker&.kill
336+
until connections.nil? || connections.empty?
337+
socket = connections.pop
338+
socket.close unless socket.closed?
339+
end
340+
server&.close
341+
WebMock.enable!
342+
end
343+
344+
def read_http_request(socket)
345+
socket.gets # request line
346+
loop do
347+
line = socket.gets
348+
break if line.nil? || line == "\r\n"
349+
end
350+
end
351+
352+
def write_http_response(socket, body)
353+
payload = JSON.generate(body)
354+
socket.write(
355+
"HTTP/1.1 200 OK\r\n" \
356+
"Content-Type: application/json\r\n" \
357+
"Content-Length: #{payload.bytesize}\r\n" \
358+
"Connection: keep-alive\r\n" \
359+
"\r\n#{payload}"
360+
)
361+
socket.flush
362+
end
363+
209364
# Regression test for https://github.com/workos/workos-ruby/issues/496
210365
#
211366
# The connection cache must be isolated per thread. A previous version

0 commit comments

Comments
 (0)