Skip to content

Commit 18505e3

Browse files
authored
refactor(bulkhead): share validation and rejection logic
Extracts Bulkhead/AsyncBulkhead's hand-duplicated constructor validation and on-timeout rejection logic into two shared functions in bulkhead.py: _validate_bulkhead_config, _emit_bulkhead_rejected. Closes a deferral explicitly named in this repo's own history. _emit_bulkhead_rejected returns the constructed exception rather than raising it, preserving the async path's explicit `from exc` chaining vs the sync path's bare raise (no active exception to chain from) -- two new tests guard this distinction directly. Kept as plain functions, local to bulkhead.py. Design: planning/changes/2026-07-13.04-bulkhead-shared-validation-rejection.md
1 parent 0b349b2 commit 18505e3

4 files changed

Lines changed: 244 additions & 34 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
summary: Share Bulkhead/AsyncBulkhead's hand-duplicated constructor validation and rejection-event-plus-BulkheadFullError construction via two module-level functions.
3+
---
4+
5+
# Design: Share Bulkhead's validation and rejection logic
6+
7+
## Summary
8+
9+
`AsyncBulkhead` and `Bulkhead` (`src/httpware/middleware/resilience/bulkhead.py`,
10+
185 lines) hand-duplicate two blocks: constructor validation
11+
(`max_concurrent`/`acquire_timeout` range checks) and the on-timeout
12+
rejection path (emit a `bulkhead.rejected` event, construct
13+
`BulkheadFullError`). Only the semaphore type and acquire-timeout
14+
mechanics genuinely differ. This change extracts both blocks into two
15+
module-level functions in the same file — `_validate_bulkhead_config` and
16+
`_emit_bulkhead_rejected` — closing a deferral this repo's own planning
17+
history named for itself. No behavior change.
18+
19+
## Motivation
20+
21+
- `planning/changes/2026-06-23.01-retry-policy-extraction.md`'s
22+
Out-of-scope section states: "Not extending the same treatment to
23+
Bulkhead in this change." That change gave `circuit_breaker.py` and
24+
`retry.py` shared decision objects (`_CircuitBreakerState`,
25+
`_RetryPolicy`); `bulkhead.py` never got the equivalent treatment.
26+
- **Verified duplication:** `AsyncBulkhead.__init__` (`:62-76`) and
27+
`Bulkhead.__init__` (`:143-155`) run the identical two `if`/`raise
28+
ValueError` checks. `AsyncBulkhead.__call__`'s `except TimeoutError`
29+
branch (`:107-123`) and `Bulkhead.__call__`'s `if not acquired:` branch
30+
(`:163-179`) build the identical `_emit_event(...)` call (same event
31+
name, level, message, attributes shape) and the identical
32+
`BulkheadFullError(max_concurrent=..., acquire_timeout=...)`
33+
construction.
34+
- **Deletion test:** delete the two shared functions and both blocks
35+
reappear verbatim at all four call sites (2 `__init__`s, 2 `__call__`s)
36+
— real, load-bearing logic, not a pass-through.
37+
38+
## Non-goals
39+
40+
- No behavior change. Validation error messages, the emitted event's
41+
name/level/message/attributes, and `BulkheadFullError`'s fields stay
42+
byte-identical.
43+
- Not a stateful class (unlike `_CircuitBreakerState`/`_RetryPolicy`).
44+
Both extracted operations are one-shot/per-call pure functions with no
45+
config or evolving state reused across calls — a class would be a
46+
namespace with no benefit, the same reasoning already applied to
47+
`client.py`'s request-assembly extraction earlier this session.
48+
- Not moving the functions to `_internal/`. `bulkhead.py`'s logic is used
49+
only by its own two classes — same situation as `_CircuitBreakerState`
50+
in `circuit_breaker.py` and `_RetryPolicy` in `retry.py`, both of which
51+
stayed local to their file.
52+
- Not touching `AsyncBulkhead._check_loop`, the semaphore acquire
53+
mechanics, or anything else in either `__call__`/`__init__` beyond the
54+
two blocks named above.
55+
56+
## Design
57+
58+
### 1. `_validate_bulkhead_config`
59+
60+
```python
61+
def _validate_bulkhead_config(*, max_concurrent: int, acquire_timeout: float | None) -> None:
62+
if max_concurrent < 1:
63+
raise ValueError(_MAX_CONCURRENT_INVALID)
64+
if acquire_timeout is not None and acquire_timeout < 0:
65+
raise ValueError(_ACQUIRE_TIMEOUT_INVALID)
66+
```
67+
68+
Replaces the two `if`/`raise` lines in both `__init__`s with:
69+
70+
```python
71+
_validate_bulkhead_config(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
72+
```
73+
74+
Uses `-> None` (not `typing.NoReturn`), matching the existing house idiom
75+
for validation helpers that raise conditionally
76+
(`_validate_max_response_body_bytes`, `_validate_httpx2_client_conflict`)
77+
— no precedent for `NoReturn` exists in this codebase, and this function
78+
doesn't always raise (only on the invalid branches), so `None` is also the
79+
technically correct annotation here regardless of style.
80+
81+
### 2. `_emit_bulkhead_rejected` — builds and returns, does not raise
82+
83+
```python
84+
def _emit_bulkhead_rejected(
85+
request: httpx2.Request,
86+
*,
87+
max_concurrent: int,
88+
acquire_timeout: float | None,
89+
) -> BulkheadFullError:
90+
_emit_event(
91+
_LOGGER,
92+
"bulkhead.rejected",
93+
level=logging.WARNING,
94+
message="bulkhead rejected request — acquire_timeout exceeded",
95+
attributes={
96+
"max_concurrent": max_concurrent,
97+
"acquire_timeout": acquire_timeout,
98+
"method": request.method,
99+
"url": str(request.url),
100+
},
101+
)
102+
return BulkheadFullError(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
103+
```
104+
105+
**Deliberately returns the exception rather than raising it.** The two
106+
call sites need different exception-chaining behavior: `AsyncBulkhead`'s
107+
rejection fires inside `except TimeoutError as exc:` and today does
108+
`raise BulkheadFullError(...) from exc` (explicit `__cause__`);
109+
`Bulkhead`'s rejection fires inside `if not acquired:` — no active
110+
exception, bare `raise BulkheadFullError(...)`. If the function raised
111+
internally, the async call site's explicit `from exc` would be lost,
112+
silently downgrading to implicit `__context__` chaining (a different
113+
traceback presentation — "the above exception was the direct cause of"
114+
vs "during handling of the above exception"). Returning the constructed
115+
exception lets each call site keep its own `raise` statement exactly as
116+
today:
117+
118+
```python
119+
# AsyncBulkhead.__call__, inside except TimeoutError as exc:
120+
raise _emit_bulkhead_rejected(
121+
request, max_concurrent=self._max_concurrent, acquire_timeout=self._acquire_timeout,
122+
) from exc
123+
124+
# Bulkhead.__call__, inside if not acquired:
125+
raise _emit_bulkhead_rejected(
126+
request, max_concurrent=self._max_concurrent, acquire_timeout=self._acquire_timeout,
127+
)
128+
```
129+
130+
### 3. Placement
131+
132+
Both functions go in `bulkhead.py`, module level, alongside the existing
133+
module constants (`_MAX_CONCURRENT_INVALID`, `_ACQUIRE_TIMEOUT_INVALID`,
134+
`_LOGGER`) — before `class AsyncBulkhead:`.
135+
136+
## Testing
137+
138+
- **Parity net:** existing `tests/test_bulkhead.py` and
139+
`tests/test_bulkhead_sync.py` stay green with the assertions they
140+
already have (message content, `BulkheadFullError` field values,
141+
`bulkhead.rejected` event via `caplog`) — byte-identical behavior is the
142+
bar.
143+
- **New chaining tests** (the one subtlety this refactor is careful to
144+
preserve, and no existing test asserts it): in `test_bulkhead.py`, add
145+
an assertion that the raised `BulkheadFullError`'s `__cause__` is the
146+
`TimeoutError` that triggered it; in `test_bulkhead_sync.py`, add an
147+
assertion that `__cause__` is `None`. Mirrors how `DecodeError.__cause__`
148+
is explicitly tested in `test_errors.py`.
149+
- **No new direct-unit-test file** for the two shared functions — the
150+
existing construction-error tests already exercise
151+
`_validate_bulkhead_config` fully (both branches, both classes), and
152+
the existing `BulkheadFullError`-field/event tests plus the new
153+
chaining tests already exercise `_emit_bulkhead_rejected` fully.
154+
- `just lint && just test` both clean; 100% coverage maintained.
155+
156+
## Risk
157+
158+
- **Chaining regression** (the main risk this design addresses directly):
159+
*Mitigation:* returning rather than raising keeps each `raise` statement
160+
exactly where it is today; the new chaining tests make a future
161+
regression (e.g. someone "simplifying" the sync call site to also use
162+
`from exc` where no exception exists, or vice versa) fail loudly.
163+
- **Behavioral drift in the shared validation/event logic** (unlikely ×
164+
low): *Mitigation:* extract verbatim under the existing green suites,
165+
which assert exact message text and field values; do not edit those
166+
assertions in this change (only add the two new chaining assertions).

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

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,34 @@
4040
_LOGGER = logging.getLogger("httpware.bulkhead")
4141

4242

43+
def _validate_bulkhead_config(*, max_concurrent: int, acquire_timeout: float | None) -> None:
44+
if max_concurrent < 1:
45+
raise ValueError(_MAX_CONCURRENT_INVALID)
46+
if acquire_timeout is not None and acquire_timeout < 0:
47+
raise ValueError(_ACQUIRE_TIMEOUT_INVALID)
48+
49+
50+
def _emit_bulkhead_rejected(
51+
request: httpx2.Request,
52+
*,
53+
max_concurrent: int,
54+
acquire_timeout: float | None,
55+
) -> BulkheadFullError:
56+
_emit_event(
57+
_LOGGER,
58+
"bulkhead.rejected",
59+
level=logging.WARNING,
60+
message="bulkhead rejected request — acquire_timeout exceeded",
61+
attributes={
62+
"max_concurrent": max_concurrent,
63+
"acquire_timeout": acquire_timeout,
64+
"method": request.method,
65+
"url": str(request.url),
66+
},
67+
)
68+
return BulkheadFullError(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
69+
70+
4371
class AsyncBulkhead:
4472
"""Async concurrency limiter middleware backed by ``asyncio.Semaphore``.
4573
@@ -65,10 +93,7 @@ def __init__(
6593
max_concurrent: int,
6694
acquire_timeout: float | None = 1.0,
6795
) -> None:
68-
if max_concurrent < 1:
69-
raise ValueError(_MAX_CONCURRENT_INVALID)
70-
if acquire_timeout is not None and acquire_timeout < 0:
71-
raise ValueError(_ACQUIRE_TIMEOUT_INVALID)
96+
_validate_bulkhead_config(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
7297
self._max_concurrent = max_concurrent
7398
self._acquire_timeout = acquire_timeout
7499
self._sem = asyncio.Semaphore(max_concurrent)
@@ -105,19 +130,8 @@ async def __call__(self, request: httpx2.Request, next: AsyncNext) -> httpx2.Res
105130
async with asyncio.timeout(self._acquire_timeout):
106131
await self._sem.acquire()
107132
except TimeoutError as exc:
108-
_emit_event(
109-
_LOGGER,
110-
"bulkhead.rejected",
111-
level=logging.WARNING,
112-
message="bulkhead rejected request — acquire_timeout exceeded",
113-
attributes={
114-
"max_concurrent": self._max_concurrent,
115-
"acquire_timeout": self._acquire_timeout,
116-
"method": request.method,
117-
"url": str(request.url),
118-
},
119-
)
120-
raise BulkheadFullError(
133+
raise _emit_bulkhead_rejected(
134+
request,
121135
max_concurrent=self._max_concurrent,
122136
acquire_timeout=self._acquire_timeout,
123137
) from exc
@@ -146,10 +160,7 @@ def __init__(
146160
max_concurrent: int,
147161
acquire_timeout: float | None = 1.0,
148162
) -> None:
149-
if max_concurrent < 1:
150-
raise ValueError(_MAX_CONCURRENT_INVALID)
151-
if acquire_timeout is not None and acquire_timeout < 0:
152-
raise ValueError(_ACQUIRE_TIMEOUT_INVALID)
163+
_validate_bulkhead_config(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
153164
self._max_concurrent = max_concurrent
154165
self._acquire_timeout = acquire_timeout
155166
self._sem = threading.Semaphore(max_concurrent)
@@ -161,19 +172,8 @@ def __call__(self, request: httpx2.Request, next: Next) -> httpx2.Response: # n
161172
# False otherwise). Both match AsyncBulkhead's contract.
162173
acquired = self._sem.acquire(timeout=self._acquire_timeout)
163174
if not acquired:
164-
_emit_event(
165-
_LOGGER,
166-
"bulkhead.rejected",
167-
level=logging.WARNING,
168-
message="bulkhead rejected request — acquire_timeout exceeded",
169-
attributes={
170-
"max_concurrent": self._max_concurrent,
171-
"acquire_timeout": self._acquire_timeout,
172-
"method": request.method,
173-
"url": str(request.url),
174-
},
175-
)
176-
raise BulkheadFullError(
175+
raise _emit_bulkhead_rejected(
176+
request,
177177
max_concurrent=self._max_concurrent,
178178
acquire_timeout=self._acquire_timeout,
179179
)

‎tests/test_bulkhead.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@ async def _hold_slot() -> None:
148148
await task
149149

150150

151+
async def test_bulkhead_full_error_chains_from_timeout() -> None:
152+
"""BulkheadFullError raised on the async timeout path chains from the TimeoutError."""
153+
handler = _SlowHandler(delay=1.0)
154+
bulkhead = AsyncBulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=_ACQUIRE_TIMEOUT_FAST)
155+
client = _client(handler, bulkhead=bulkhead)
156+
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)
163+
164+
with pytest.raises(BulkheadFullError) as exc_info:
165+
await client.get("https://example.test/fast")
166+
167+
assert isinstance(exc_info.value.__cause__, TimeoutError)
168+
169+
# Cancel the lingering slow task to avoid polluting the event loop.
170+
task.cancel()
171+
with contextlib.suppress(asyncio.CancelledError):
172+
await task
173+
174+
151175
async def test_bounded_wait_raises_bulkhead_full_error() -> None:
152176
"""With max_concurrent=1 and acquire_timeout=0.02, the second call raises after ~20ms.
153177

‎tests/test_bulkhead_sync.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ def test_acquire_timeout_rejects_when_no_slot_available() -> None:
129129
holder.join()
130130

131131

132+
def test_bulkhead_full_error_no_chaining() -> None:
133+
"""BulkheadFullError raised on the sync timeout path has no __cause__ (no active exception)."""
134+
handler = _SlowHandler(delay=0.1)
135+
client = _client(
136+
handler,
137+
bulkhead=Bulkhead(max_concurrent=_MAX_CONCURRENT_1, acquire_timeout=_ACQUIRE_TIMEOUT_FAST),
138+
)
139+
140+
holder = threading.Thread(target=client.get, args=("https://example.test/hold",))
141+
holder.start()
142+
# Give the holder time to acquire the only slot
143+
time.sleep(0.01)
144+
try:
145+
with pytest.raises(BulkheadFullError) as exc_info:
146+
client.get("https://example.test/blocked")
147+
assert exc_info.value.__cause__ is None
148+
finally:
149+
holder.join()
150+
151+
132152
def test_releases_slot_on_exception() -> None:
133153
"""A handler that raises must still cause the slot to be released."""
134154
calls = []

0 commit comments

Comments
 (0)