Skip to content

Commit 7e72503

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/send-with-response
2 parents ff3329c + df729fa commit 7e72503

4 files changed

Lines changed: 79 additions & 2 deletions

File tree

docs/middleware.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ The same protocol shape, sync flavor. Use these when wiring middleware into a sy
142142

143143
```python
144144
from httpware import Middleware, Next, before_request, after_response, on_error
145-
from httpware.middleware.chain import compose
146145
```
147146

148147
A sync `Middleware` is a structural protocol — any callable with the right signature satisfies it:

docs/testing.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ def test_get_returns_typed_response() -> None:
5656
For tests that need to vary the response by call count or assert on the requests that came in, use a handler with instance state:
5757

5858
```python
59+
from httpware import AsyncRetry
60+
61+
5962
class _ResponseSequence:
6063
"""Returns each status in order; records every request received."""
6164

src/httpware/middleware/resilience/bulkhead.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
1010
AsyncBulkhead is the sharable unit — pass the same instance to multiple
1111
AsyncClient(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

1421
import asyncio
@@ -24,6 +31,11 @@
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()

tests/test_bulkhead.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,45 @@ async def slow_handler(request: httpx2.Request) -> httpx2.Response:
431431
assert record.acquire_timeout == 0.0 # ty: ignore[unresolved-attribute]
432432
assert record.method == "GET" # ty: ignore[unresolved-attribute]
433433
assert "example.test/y" in record.url # ty: ignore[unresolved-attribute]
434+
435+
436+
# ───── Single-event-loop guard ──────────────────────────────────────────────
437+
438+
439+
async def test_first_acquire_captures_running_loop() -> None:
440+
"""AsyncBulkhead binds to whichever loop first acquires a slot."""
441+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_1)
442+
assert bulkhead._loop is None # noqa: SLF001
443+
handler = _SlowHandler(delay=0.0)
444+
async with _client(handler, bulkhead=bulkhead) as client:
445+
await client.get("https://example.test/x")
446+
assert bulkhead._loop is asyncio.get_running_loop() # noqa: SLF001
447+
448+
449+
async def test_same_loop_succeeds_across_multiple_acquires() -> None:
450+
"""Repeated acquires on the same loop never trigger the cross-loop guard."""
451+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_2)
452+
handler = _SlowHandler(delay=0.0)
453+
async with _client(handler, bulkhead=bulkhead) as client:
454+
for _ in range(5):
455+
response = await client.get("https://example.test/x")
456+
assert response.status_code == HTTPStatus.OK
457+
458+
459+
def test_cross_loop_acquire_raises_runtimeerror() -> None:
460+
"""A bulkhead first used on one loop, then reused on another, raises RuntimeError.
461+
462+
Each asyncio.run() call creates a fresh event loop and tears it down on
463+
exit. Sharing one AsyncBulkhead instance across two asyncio.run() calls
464+
is the cross-loop case the guard prevents.
465+
"""
466+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_1)
467+
handler = _SlowHandler(delay=0.0)
468+
469+
async def _run_once() -> None:
470+
async with _client(handler, bulkhead=bulkhead) as client:
471+
await client.get("https://example.test/x")
472+
473+
asyncio.run(_run_once()) # captures loop L1, then L1 closes
474+
with pytest.raises(RuntimeError, match="AsyncBulkhead is bound to a single event loop"):
475+
asyncio.run(_run_once())

0 commit comments

Comments
 (0)