55"""
66
77import asyncio
8- import contextlib
98import logging
109from collections .abc import Callable , Coroutine
1110from http import HTTPStatus
@@ -48,6 +47,29 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:
4847 self .in_flight -= 1
4948
5049
50+ class _GatedHandler :
51+ """Holds the first `capacity` requests until `release` is set; later requests return at once."""
52+
53+ def __init__ (self , capacity : int ) -> None :
54+ self .capacity = capacity
55+ self .entered = 0
56+ self .full = asyncio .Event ()
57+ self .release = asyncio .Event ()
58+
59+ async def __call__ (self , request : httpx2 .Request ) -> httpx2 .Response :
60+ self .entered += 1
61+ if self .entered == self .capacity :
62+ self .full .set ()
63+ if self .entered <= self .capacity :
64+ await self .release .wait ()
65+ return httpx2 .Response (HTTPStatus .OK , request = request )
66+
67+ async def wait_full (self ) -> None :
68+ """Wait until `capacity` requests are inside the handler."""
69+ async with asyncio .timeout (5.0 ):
70+ await self .full .wait ()
71+
72+
5173def _client (
5274 handler : Callable [[httpx2 .Request ], httpx2 .Response ]
5375 | Callable [[httpx2 .Request ], Coroutine [Any , Any , httpx2 .Response ]],
@@ -150,51 +172,39 @@ async def test_max_concurrent_2_observes_at_most_2_in_flight() -> None:
150172
151173async def test_raises_bulkhead_full_error_when_acquire_timeout_exceeded () -> None :
152174 """Slot is held by a slow request; a second request with a tiny timeout raises BulkheadFullError."""
153- handler = _SlowHandler ( delay = 1.0 )
175+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
154176 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = _ACQUIRE_TIMEOUT_FAST )
155177 client = _client (handler , bulkhead = bulkhead )
156178
157- async def _hold_slot () -> None :
158- await client .get ("https://example.test/slow" )
159-
160- task = asyncio .create_task (_hold_slot ())
161- # Yield to let the slow request acquire the semaphore.
162- await asyncio .sleep (0 )
179+ holder = asyncio .create_task (client .get ("https://example.test/slow" ))
180+ await handler .wait_full ()
163181
164182 with pytest .raises (BulkheadFullError ) as exc_info :
165183 await client .get ("https://example.test/fast" )
166184
167185 assert exc_info .value .max_concurrent == _MAX_CONCURRENT_1
168186 assert exc_info .value .acquire_timeout == _ACQUIRE_TIMEOUT_FAST
169187
170- # Cancel the lingering slow task to avoid polluting the event loop.
171- task .cancel ()
172- with contextlib .suppress (asyncio .CancelledError ):
173- await task
188+ handler .release .set ()
189+ await holder
174190
175191
176192async def test_bulkhead_full_error_chains_from_timeout () -> None :
177193 """BulkheadFullError raised on the async timeout path chains from the TimeoutError."""
178- handler = _SlowHandler ( delay = 1.0 )
194+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
179195 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = _ACQUIRE_TIMEOUT_FAST )
180196 client = _client (handler , bulkhead = bulkhead )
181197
182- async def _hold_slot () -> None :
183- await client .get ("https://example.test/slow" )
184-
185- task = asyncio .create_task (_hold_slot ())
186- # Yield to let the slow request acquire the semaphore.
187- await asyncio .sleep (0 )
198+ holder = asyncio .create_task (client .get ("https://example.test/slow" ))
199+ await handler .wait_full ()
188200
189201 with pytest .raises (BulkheadFullError ) as exc_info :
190202 await client .get ("https://example.test/fast" )
191203
192204 assert isinstance (exc_info .value .__cause__ , TimeoutError )
193205
194- # Cancel the lingering slow task to avoid polluting the event loop.
195- task .cancel ()
196- with contextlib .suppress (asyncio .CancelledError ):
197- await task
206+ handler .release .set ()
207+ await holder
198208
199209
200210async def test_bounded_wait_raises_bulkhead_full_error () -> None :
@@ -205,34 +215,36 @@ async def test_bounded_wait_raises_bulkhead_full_error() -> None:
205215 BulkheadFullError fields (max_concurrent / acquire_timeout) carry the
206216 configured values.
207217 """
208- handler = _SlowHandler ( delay = _ACQUIRE_TIMEOUT_LONG ) # holds slot for 100ms
218+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
209219 client = _client (
210220 handler ,
211221 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = _ACQUIRE_TIMEOUT_SHORT ),
212222 )
213223
214224 first = asyncio .create_task (client .get ("https://example.test/a" ))
215- await asyncio . sleep ( 0.005 ) # let first acquire the slot
225+ await handler . wait_full ()
216226 with pytest .raises (BulkheadFullError ) as info :
217227 await client .get ("https://example.test/b" )
218228 assert info .value .max_concurrent == _MAX_CONCURRENT_1
219229 assert info .value .acquire_timeout == _ACQUIRE_TIMEOUT_SHORT
220- await first # cleanup
230+ handler .release .set ()
231+ await first
221232
222233
223234async def test_acquire_timeout_zero_fails_fast () -> None :
224235 """With acquire_timeout=0, the second call raises immediately without waiting."""
225- handler = _SlowHandler ( delay = _ACQUIRE_TIMEOUT_LONG )
236+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
226237 client = _client (
227238 handler ,
228239 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = 0 ),
229240 )
230241
231242 first = asyncio .create_task (client .get ("https://example.test/a" ))
232- await asyncio . sleep ( 0.005 )
243+ await handler . wait_full ( )
233244 with pytest .raises (BulkheadFullError ) as info :
234245 await client .get ("https://example.test/b" )
235246 assert info .value .acquire_timeout == 0
247+ handler .release .set ()
236248 await first
237249
238250
@@ -276,18 +288,17 @@ def handler(request: httpx2.Request) -> httpx2.Response:
276288
277289async def test_slot_released_on_cancellation () -> None :
278290 """If the calling task is cancelled while next() runs, the slot is released."""
279- handler = _SlowHandler ( delay = 0.5 ) # would block indefinitely
291+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
280292 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = 0 )
281293 client = _client (handler , bulkhead = bulkhead )
282294
283295 first = asyncio .create_task (client .get ("https://example.test/a" ))
284- await asyncio . sleep ( 0.01 ) # let first acquire and start sleeping in handler
296+ await handler . wait_full ()
285297 first .cancel ()
286298 with pytest .raises (asyncio .CancelledError ):
287299 await first
288300
289301 # Slot must now be released — fail-fast=0 next call proves it.
290- handler .delay = 0.0 # speed up the next request
291302 response = await client .get ("https://example.test/b" )
292303 assert response .status_code == HTTPStatus .OK
293304
@@ -300,25 +311,26 @@ async def test_cancellation_before_acquire_does_not_hold_slot() -> None:
300311 release (it must NOT take the slot the cancelled task was waiting for). And once
301312 first releases, the fresh request must complete normally.
302313 """
303- handler = _SlowHandler ( delay = 0.05 )
314+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
304315 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = None )
305316 client = _client (handler , bulkhead = bulkhead )
306317
307318 first = asyncio .create_task (client .get ("https://example.test/a" ))
308- await asyncio . sleep ( 0.005 ) # first acquires
319+ await handler . wait_full ()
309320 second = asyncio .create_task (client .get ("https://example.test/b" )) # waits for slot
310- await asyncio .sleep (0.005 ) # ensure second is parked on acquire
321+ await asyncio .sleep (0 ) # ensure second is parked on acquire
311322 second .cancel ()
312323 with pytest .raises (asyncio .CancelledError ):
313324 await second
314325
315326 # Third request issued while first still holds the slot — must not see a phantom
316327 # free slot left by the cancelled second.
317328 third = asyncio .create_task (client .get ("https://example.test/c" ))
329+ handler .release .set ()
318330 first_response , third_response = await asyncio .gather (first , third )
319331 assert first_response .status_code == HTTPStatus .OK
320332 assert third_response .status_code == HTTPStatus .OK
321- assert handler .calls == 2 # noqa: PLR2004 — first and third reached handler; second never did
333+ assert handler .entered == 2 # noqa: PLR2004 — first and third reached handler; second never did
322334
323335
324336# Constructed at module scope on purpose — pins the construct-outside-loop behavior.
@@ -416,7 +428,7 @@ async def _sleep(_: float) -> None: # don't actually wait between retries
416428
417429async def test_bulkhead_full_error_is_not_retried_by_retry () -> None :
418430 """AsyncRetry does NOT retry BulkheadFullError — it's neither a StatusError nor a NetworkError/TimeoutError."""
419- handler = _SlowHandler ( delay = 0.5 ) # holds the slot indefinitely
431+ handler = _GatedHandler ( capacity = _MAX_CONCURRENT_1 )
420432 bulkhead = AsyncBulkhead (max_concurrent = _MAX_CONCURRENT_1 , acquire_timeout = 0 )
421433 transport = httpx2 .MockTransport (handler )
422434
@@ -432,30 +444,23 @@ async def test_bulkhead_full_error_is_not_retried_by_retry() -> None:
432444 ],
433445 )
434446
435- # Fill the slot with a long-lived task.
436447 first = asyncio .create_task (client .get ("https://example.test/holder" ))
437- await asyncio . sleep ( 0.01 )
448+ await handler . wait_full ( )
438449
439450 # Second call hits a full AsyncBulkhead. AsyncRetry must NOT swallow + retry it.
440451 with pytest .raises (BulkheadFullError ):
441452 await client .get ("https://example.test/rejected" )
442453 mock_sleep .assert_not_called () # AsyncRetry never slept — it didn't try to retry
443454
444- # Cleanup.
445- first .cancel ()
446- with contextlib .suppress (asyncio .CancelledError ):
447- await first
455+ handler .release .set ()
456+ await first
448457
449458
450459async def test_bulkhead_rejected_emits_observability_event (caplog : pytest .LogCaptureFixture ) -> None :
451460 """When the bulkhead rejects a request via acquire_timeout, emit one WARNING on httpware.bulkhead."""
452461 bulkhead = AsyncBulkhead (max_concurrent = 1 , acquire_timeout = 0.0 )
453-
454- async def slow_handler (request : httpx2 .Request ) -> httpx2 .Response :
455- await asyncio .sleep (0.05 )
456- return httpx2 .Response (HTTPStatus .OK , request = request )
457-
458- transport = httpx2 .MockTransport (slow_handler )
462+ handler = _GatedHandler (capacity = 1 )
463+ transport = httpx2 .MockTransport (handler )
459464 client = AsyncClient (
460465 httpx2_client = httpx2 .AsyncClient (transport = transport ),
461466 middleware = [bulkhead ],
@@ -464,11 +469,12 @@ async def slow_handler(request: httpx2.Request) -> httpx2.Response:
464469 async with client :
465470 # First request occupies the only slot. Second should be rejected immediately.
466471 first_task = asyncio .create_task (client .get ("https://example.test/x" ))
467- await asyncio . sleep ( 0 ) # let first_task start and acquire the slot
472+ await handler . wait_full ()
468473
469474 with caplog .at_level (logging .WARNING , logger = "httpware.bulkhead" ), pytest .raises (BulkheadFullError ):
470475 await client .get ("https://example.test/y" )
471476
477+ handler .release .set ()
472478 await first_task
473479
474480 bulkhead_records = [r for r in caplog .records if r .name == "httpware.bulkhead" ]
0 commit comments