@@ -233,38 +233,20 @@ def test_sync_api_transport_cache_is_shared_across_threads(test_api_key):
233233 reset_sync_api_transports ()
234234
235235
236- def test_sync_api_client_reused_within_thread_and_shares_transport_across_threads (
237- test_api_key ,
238- ):
236+ def test_sync_api_client_is_shared_across_threads (test_api_key ):
237+ # httpx.Client is thread-safe and the pyqwest transport underneath is
238+ # too, so a single client (and its pool) serves all threads — the
239+ # per-thread client caching this replaced is gone.
239240 reset_sync_api_transports ()
240241 config = ConnectionConfig (api_key = test_api_key )
241242 api_client = get_sync_api_client (config )
242243
243- def get_worker_client_ids ():
244- httpx_client = api_client .get_httpx_client ()
245- try :
246- return (
247- id (httpx_client ),
248- id (api_client .get_httpx_client ()),
249- id (httpx_client ._transport ),
250- )
251- finally :
252- httpx_client .close ()
253-
254244 try :
255245 main_client = api_client .get_httpx_client ()
256- (
257- worker_client_id ,
258- worker_cached_client_id ,
259- worker_transport_id ,
260- ) = run_in_worker_thread (get_worker_client_ids )
246+ worker_client = run_in_worker_thread (api_client .get_httpx_client )
261247
262248 assert api_client .get_httpx_client () is main_client
263- # The httpx client wrapper stays per-thread, but every thread's
264- # client delegates to the same shared pyqwest transport.
265- assert worker_client_id == worker_cached_client_id
266- assert worker_client_id != id (main_client )
267- assert worker_transport_id == id (main_client ._transport )
249+ assert worker_client is main_client
268250 finally :
269251 main_client .close ()
270252 reset_sync_api_transports ()
@@ -345,42 +327,27 @@ async def test_async_api_client_applies_request_timeout(test_api_key):
345327
346328
347329@pytest .mark .asyncio
348- async def test_async_api_client_cache_reuses_within_loop_and_shares_transport (
349- test_api_key ,
350- ):
330+ async def test_async_api_client_is_shared_across_loops (test_api_key ):
331+ # pyqwest's I/O runs on its own Rust runtime, so neither the transport
332+ # nor the httpx client wrapper is bound to an event loop — a single
333+ # client serves all loops (the per-loop client caching this replaced is
334+ # gone).
351335 reset_async_api_transports ()
352336 config = ConnectionConfig (api_key = test_api_key )
353337 api_client = get_async_api_client (config )
354338
355- async def get_client_ids ():
356- httpx_client = api_client .get_async_httpx_client ()
357- try :
358- return (
359- id (httpx_client ),
360- id (api_client .get_async_httpx_client ()),
361- id (httpx_client ._transport ),
362- )
363- finally :
364- await httpx_client .aclose ()
339+ async def get_client ():
340+ return api_client .get_async_httpx_client ()
365341
366342 try :
367343 main_client = api_client .get_async_httpx_client ()
368- (
369- worker_client_id ,
370- worker_cached_client_id ,
371- worker_transport_id ,
372- ) = await asyncio .get_running_loop ().run_in_executor (
344+ other_loop_client = await asyncio .get_running_loop ().run_in_executor (
373345 None ,
374- lambda : asyncio .run (get_client_ids ()),
346+ lambda : asyncio .run (get_client ()),
375347 )
376348
377349 assert api_client .get_async_httpx_client () is main_client
378- # The httpx client wrapper stays per-loop, but pyqwest's I/O runs on
379- # its own Rust runtime, so every loop's client delegates to the same
380- # shared transport.
381- assert worker_client_id == worker_cached_client_id
382- assert worker_client_id != id (main_client )
383- assert worker_transport_id == id (main_client ._transport )
350+ assert other_loop_client is main_client
384351 finally :
385352 await main_client .aclose ()
386353 reset_async_api_transports ()
@@ -415,7 +382,9 @@ async def get_transport():
415382 reset_async_api_transports ()
416383
417384
418- def test_async_api_client_not_reused_across_sequential_loops (test_api_key ):
385+ def test_async_api_client_reused_across_sequential_loops (test_api_key ):
386+ # A closed loop doesn't strand the client: nothing in it is loop-bound,
387+ # so a later loop reuses the same client and shared transport.
419388 reset_async_api_transports ()
420389 config = ConnectionConfig (api_key = test_api_key )
421390 api_client = get_async_api_client (config )
@@ -429,22 +398,18 @@ async def get_client():
429398 loop_a = asyncio .new_event_loop ()
430399 try :
431400 client_a = loop_a .run_until_complete (get_client ())
432- loop_a .run_until_complete (client_a .aclose ())
433401 finally :
434402 loop_a .close ()
435403 del loop_a
436404 gc .collect ()
437405
438- assert len (api_client ._async_clients ) == 0
439-
440406 loop_b = asyncio .new_event_loop ()
441407 try :
442408 client_b = loop_b .run_until_complete (get_client ())
443- loop_b .run_until_complete (client_b .aclose ())
444409 finally :
445410 loop_b .close ()
446411
447- assert client_b is not client_a
412+ assert client_b is client_a
448413 finally :
449414 reset_async_api_transports ()
450415
@@ -517,6 +482,47 @@ def test_sync_api_client_round_trips_through_pyqwest(test_api_key, echo_server):
517482 reset_sync_api_transports ()
518483
519484
485+ def test_sync_api_client_serves_concurrent_threads (test_api_key , echo_server ):
486+ # The scenario the removed per-thread client caching used to guard: one
487+ # client, one shared pyqwest pool, many threads at once.
488+ reset_sync_api_transports ()
489+ config = ConnectionConfig (api_key = test_api_key , api_url = echo_server )
490+ api_client = get_sync_api_client (config )
491+ httpx_client = api_client .get_httpx_client ()
492+
493+ def request (i : int ) -> tuple [int , str ]:
494+ response = httpx_client .request ("GET" , f"/sandboxes/{ i } " )
495+ return response .status_code , response .json ()["path" ]
496+
497+ try :
498+ with ThreadPoolExecutor (max_workers = 16 ) as executor :
499+ results = list (executor .map (request , range (32 )))
500+
501+ assert results == [(200 , f"/sandboxes/{ i } " ) for i in range (32 )]
502+ finally :
503+ httpx_client .close ()
504+ reset_sync_api_transports ()
505+
506+
507+ @pytest .mark .asyncio
508+ async def test_async_api_client_serves_concurrent_requests (test_api_key , echo_server ):
509+ reset_async_api_transports ()
510+ config = ConnectionConfig (api_key = test_api_key , api_url = echo_server )
511+ api_client = get_async_api_client (config )
512+ httpx_client = api_client .get_async_httpx_client ()
513+
514+ async def request (i : int ) -> tuple [int , str ]:
515+ response = await httpx_client .request ("GET" , f"/sandboxes/{ i } " )
516+ return response .status_code , response .json ()["path" ]
517+
518+ try :
519+ results = await asyncio .gather (* (request (i ) for i in range (32 )))
520+ assert list (results ) == [(200 , f"/sandboxes/{ i } " ) for i in range (32 )]
521+ finally :
522+ await httpx_client .aclose ()
523+ reset_async_api_transports ()
524+
525+
520526@pytest .mark .asyncio
521527async def test_async_api_client_round_trips_through_pyqwest (test_api_key , echo_server ):
522528 reset_async_api_transports ()
0 commit comments