Skip to content

Commit b9e2861

Browse files
authored
test: synchronize bulkhead capacity tests on events instead of sleeps (#185)
The fail-fast and rejection tests assumed a fixed sleep let the holders acquire their slots and that a handler delay kept them held until the rejections ran. Under CPU load the holders released early and the extra requests went through. The handlers now hold slots until the test releases them.
1 parent 9025d02 commit b9e2861

4 files changed

Lines changed: 136 additions & 85 deletions

File tree

‎tests/test_bulkhead.py‎

Lines changed: 55 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"""
66

77
import asyncio
8-
import contextlib
98
import logging
109
from collections.abc import Callable, Coroutine
1110
from 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+
5173
def _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

151173
async 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

176192
async 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

200210
async 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

223234
async 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

277289
async 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

417429
async 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

450459
async 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"]

‎tests/test_bulkhead_props.py‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ async def __call__(self, request: httpx2.Request) -> httpx2.Response:
4040
self.in_flight -= 1
4141

4242

43+
class _GatedHandler:
44+
"""Holds the first `capacity` requests until `release` is set; later requests return at once."""
45+
46+
def __init__(self, capacity: int) -> None:
47+
self.capacity = capacity
48+
self.entered = 0
49+
self.full = asyncio.Event()
50+
self.release = asyncio.Event()
51+
52+
async def __call__(self, request: httpx2.Request) -> httpx2.Response:
53+
self.entered += 1
54+
if self.entered == self.capacity:
55+
self.full.set()
56+
if self.entered <= self.capacity:
57+
await self.release.wait()
58+
return httpx2.Response(HTTPStatus.OK, request=request)
59+
60+
async def wait_full(self) -> None:
61+
"""Wait until `capacity` requests are inside the handler."""
62+
async with asyncio.timeout(5.0):
63+
await self.full.wait()
64+
65+
4366
@given(
4467
max_concurrent=st.integers(min_value=1, max_value=8),
4568
n_requests=st.integers(min_value=1, max_value=32),
@@ -71,24 +94,22 @@ async def test_fail_fast_rejects_when_at_capacity(
7194
max_concurrent: int,
7295
extra_requests: int,
7396
) -> None:
74-
handler = _InFlightHandler(delay=0.05) # hold slots long enough for fail-fast to fire
97+
handler = _GatedHandler(capacity=max_concurrent)
7598
transport = httpx2.MockTransport(handler)
7699
client = AsyncClient(
77100
httpx2_client=httpx2.AsyncClient(transport=transport),
78101
middleware=[AsyncBulkhead(max_concurrent=max_concurrent, acquire_timeout=0)],
79102
)
80103

81-
# Fill the bulkhead with max_concurrent long-running tasks.
82104
holders = [asyncio.create_task(client.get(f"https://example.test/hold-{i}")) for i in range(max_concurrent)]
83-
await asyncio.sleep(0.005) # let the holders acquire their slots
84-
85-
# Any extra requests should fail fast with BulkheadFullError.
86-
for i in range(extra_requests):
87-
with pytest.raises(BulkheadFullError):
88-
await client.get(f"https://example.test/extra-{i}")
89-
90-
# Cleanup the holders.
91-
await asyncio.gather(*holders)
105+
try:
106+
await handler.wait_full()
107+
for i in range(extra_requests):
108+
with pytest.raises(BulkheadFullError):
109+
await client.get(f"https://example.test/extra-{i}")
110+
finally:
111+
handler.release.set()
112+
await asyncio.gather(*holders)
92113

93114

94115
@given(

0 commit comments

Comments
 (0)