Skip to content

Commit 853de67

Browse files
committed
fix: explicitly evict connections if request does not complete as success
Wrap Net::HTTP#request in begin/ensure so that any exit other than a returned response — including exceptions outside StandardError such as an application-level Timeout.timeout or Thread#kill — removes and closes the cached keep-alive connection instead of leaving it mid-stream for the next request on the same thread to pick up. Adds a real-socket regression test plus StubConnection-based coverage of the evict/keep paths, and a teardown that clears the fiber-local connection cache between tests.
1 parent feddca7 commit 853de67

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

lib/workos/base_client.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,20 @@ def execute_request(request:, request_options: nil)
136136
loop do
137137
log(:debug, "request start", method: request.method, path: request.path, attempt: attempt + 1)
138138
http = connection_for(base, timeout)
139-
response = http.request(request)
139+
request_completed = false
140+
begin
141+
response = http.request(request)
142+
request_completed = true
143+
ensure
144+
# Any exit from #request other than a returned response can leave
145+
# the socket mid-stream: a connection error the rescue below knows
146+
# about, one it doesn't (OpenSSL::SSL::SSLError,
147+
# Net::HTTPBadResponse), or a non-local exit such as an
148+
# application-level Timeout.timeout or Thread#kill. A half-read
149+
# socket handed back to the pool desyncs the *next* request on this
150+
# thread, so drop it here rather than in the rescue.
151+
evict_connection(base) unless request_completed
152+
end
140153
return response if response.is_a?(Net::HTTPSuccess)
141154

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

test/workos/test_base_client.rb

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,36 @@ 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
88106

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+
89115
def test_request_dispatches_known_methods
90116
client = RecordingClient.new(api_key: "sk_test_123")
91117

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

0 commit comments

Comments
 (0)