Skip to content

Commit 4874634

Browse files
committed
perf: skip asyncio.timeout in AsyncBulkhead when a slot is free
1 parent 5d24437 commit 4874634

2 files changed

Lines changed: 29 additions & 4 deletions

File tree

‎src/httpware/middleware/resilience/bulkhead.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""AsyncBulkhead middleware — concurrency limiter via asyncio.Semaphore.
22
33
The middleware owns an asyncio.Semaphore(max_concurrent). On each request,
4-
it acquires a slot (bounded by acquire_timeout via asyncio.timeout) and
5-
releases the slot in a try/finally so success, exceptions, and cancellation
6-
all release deterministically.
4+
it acquires a slot and releases it in a try/finally so success, exceptions,
5+
and cancellation all release deterministically. When every slot is taken,
6+
asyncio.timeout bounds the wait by acquire_timeout.
77
88
AsyncBulkhead is the sharable unit — pass the same instance to multiple
99
AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
@@ -112,7 +112,7 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
112112
"""Acquire a slot (bounded by acquire_timeout), invoke next, release."""
113113
self._check_loop()
114114
try:
115-
if self._acquire_timeout is None:
115+
if self._acquire_timeout is None or not self._sem.locked():
116116
await self._sem.acquire()
117117
else:
118118
async with asyncio.timeout(self._acquire_timeout):

‎tests/test_bulkhead.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@ async def test_serializes_at_capacity() -> None:
110110
assert handler.max_in_flight == 1 # cap honored
111111

112112

113+
async def test_a_freed_slot_goes_to_the_queued_waiter_before_a_new_call() -> None:
114+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=_ACQUIRE_TIMEOUT_LONG)
115+
request = httpx2.Request("GET", "https://example.test/x")
116+
release = asyncio.Event()
117+
order: list[str] = []
118+
119+
def _next(name: str) -> Callable[[httpx2.Request], Coroutine[Any, Any, httpx2.Response]]:
120+
async def _send(request: httpx2.Request) -> httpx2.Response:
121+
order.append(name)
122+
if name == "holder":
123+
await release.wait()
124+
return httpx2.Response(HTTPStatus.OK, request=request)
125+
126+
return _send
127+
128+
holder = asyncio.create_task(bulkhead(request, _next("holder")))
129+
await asyncio.sleep(0)
130+
queued = asyncio.create_task(bulkhead(request, _next("queued")))
131+
await asyncio.sleep(0)
132+
release.set()
133+
late = asyncio.create_task(bulkhead(request, _next("late")))
134+
await asyncio.gather(holder, queued, late)
135+
assert order == ["holder", "queued", "late"]
136+
137+
113138
async def test_max_concurrent_2_observes_at_most_2_in_flight() -> None:
114139
handler = _SlowHandler(delay=0.02)
115140
client = _client(handler, bulkhead=AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_2, acquire_timeout=None))

0 commit comments

Comments
 (0)