99
1010AsyncBulkhead is the sharable unit — pass the same instance to multiple
1111AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients.
12+
13+ AsyncBulkhead is single-event-loop: the underlying asyncio.Semaphore binds
14+ to whichever loop first awaits it, and cross-loop wake-ups are not thread
15+ safe. A single instance acquired from a second event loop (e.g. another
16+ thread running asyncio.run) raises RuntimeError on entry rather than
17+ deadlocking silently. To cap a sync+async or cross-thread workload, use
18+ a Bulkhead and an AsyncBulkhead with matching max_concurrent.
1219"""
1320
1421import asyncio
2431
2532_MAX_CONCURRENT_INVALID = "max_concurrent must be >= 1"
2633_ACQUIRE_TIMEOUT_INVALID = "acquire_timeout must be >= 0"
34+ _ASYNCBULKHEAD_CROSS_LOOP_MSG = (
35+ "AsyncBulkhead is bound to a single event loop. First seen on {first!r}; "
36+ "current request is on {current!r}. Use one AsyncBulkhead per loop; "
37+ "cross-thread sharing requires the sync Bulkhead primitive."
38+ )
2739
2840_LOGGER = logging .getLogger ("httpware.bulkhead" )
2941
@@ -42,7 +54,8 @@ class AsyncBulkhead:
4254 Defaults to ``1.0``. ``None`` waits forever; ``0`` fails fast. Must be
4355 ``>= 0`` (or ``None``).
4456
45- See the module docstring for the algorithm and middleware-ordering guidance.
57+ See the module docstring for the algorithm, middleware-ordering guidance,
58+ and the single-event-loop constraint.
4659
4760 """
4861
@@ -59,9 +72,29 @@ def __init__(
5972 self ._max_concurrent = max_concurrent
6073 self ._acquire_timeout = acquire_timeout
6174 self ._sem = asyncio .Semaphore (max_concurrent )
75+ self ._loop : asyncio .AbstractEventLoop | None = None
76+ self ._loop_lock = threading .Lock ()
77+
78+ def _check_loop (self ) -> None :
79+ current = asyncio .get_running_loop ()
80+ cached = self ._loop
81+ if cached is current :
82+ return
83+ if cached is not None :
84+ raise RuntimeError (
85+ _ASYNCBULKHEAD_CROSS_LOOP_MSG .format (first = cached , current = current ),
86+ )
87+ with self ._loop_lock :
88+ if self ._loop is None :
89+ self ._loop = current
90+ elif self ._loop is not current :
91+ raise RuntimeError (
92+ _ASYNCBULKHEAD_CROSS_LOOP_MSG .format (first = self ._loop , current = current ),
93+ )
6294
6395 async def __call__ (self , request : httpx2 .Request , next : AsyncNext ) -> httpx2 .Response : # noqa: A002
6496 """Acquire a slot (bounded by acquire_timeout), invoke next, release."""
97+ self ._check_loop ()
6598 try :
6699 if self ._acquire_timeout is None :
67100 await self ._sem .acquire ()
0 commit comments