Skip to content

Commit 8cf01be

Browse files
authored
Merge pull request #37 from modern-python/fix/test-mop-up
test(mop-up): close 5 audit findings (0.8.6)
2 parents a47e2be + 085baa0 commit 8cf01be

8 files changed

Lines changed: 1440 additions & 12 deletions

‎planning/plans/2026-06-08-test-mop-up-plan.md‎

Lines changed: 810 additions & 0 deletions
Large diffs are not rendered by default.

‎planning/releases/0.8.6.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# httpware 0.8.6 — test mop-up
2+
3+
**Patch release. Test-only changes. No API change, no production code change, no behavior change for users.** Closes 5 audit findings — all in the test suite.
4+
5+
## What changed
6+
7+
- **`test_no_slot_leak_after_drain` is behavioral, not internals-peek.** The Hypothesis property test for `AsyncBulkhead` no longer asserts against `bulkhead._sem._value`; it submits `max_concurrent` fresh acquires after drain and confirms they all succeed under a tight `acquire_timeout`.
8+
- **`test_threading_with_shared_budget` asserts the exact deposit count.** The previous `len(budget._deposits) > 0` was a smoke check that would have passed under deque corruption with even one survivor. The new assertion locks the count to `(N_SYNC_THREADS * N_OPS_PER_THREAD) + N_ASYNC_TASKS` = 220, made deterministic by the 0.8.3 deposit-hoist.
9+
- **`test_optional_extras_pydantic_missing.py` covers the sync `Client` escape hatch.** The async test pinned `AsyncClient(decoder=fake)` bypassing the pydantic fail-fast; the sync `Client` had no peer. Now it does. `_FakeDecoder` hoisted to module top to keep the two tests DRY.
10+
- **Sync `Bulkhead` has Hypothesis property tests.** New file `tests/test_bulkhead_sync_props.py` mirrors `tests/test_bulkhead_props.py` using `threading.Thread` + `ThreadPoolExecutor` instead of `asyncio.gather`. Three properties: in-flight never exceeds cap; fail-fast rejects at capacity; no slot leak after drain.
11+
- **Sync `on_error` `BaseException` propagation is tested.** Two new tests in `test_middleware_sync.py` pin the invariant that the sync `on_error` decorator's `except Exception` clause does NOT catch `KeyboardInterrupt` or `SystemExit` — both must propagate through `compose`.
12+
13+
## Audit status
14+
15+
35 audit findings, all addressed across 0.8.1 → 0.8.6 plus the in-place doc-staleness sweep on `main`. One chunk-3 hand-review item was excluded as INVALID (the audit looked for `AsyncClient` construction tests in `tests/test_client_methods.py` — they actually live in `tests/test_client_construction.py` + `tests/test_client_lifecycle.py`).
16+
17+
## Upgrade
18+
19+
```bash
20+
uv add httpware==0.8.6
21+
# or
22+
pip install -U 'httpware==0.8.6'
23+
```
24+
25+
No import changes. No API changes. Nothing observable from the consumer side.

‎planning/specs/2026-06-08-test-mop-up-design.md‎

Lines changed: 396 additions & 0 deletions
Large diffs are not rendered by default.

‎tests/test_bulkhead_props.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ async def test_no_slot_leak_after_drain(max_concurrent: int, n_requests: int) ->
108108

109109
await asyncio.gather(*(client.get(f"https://example.test/{i}") for i in range(n_requests)))
110110

111-
# AsyncBulkhead should be drained — _value equals max_concurrent again.
112-
# asyncio.Semaphore._value is implementation detail but reliable across CPython 3.11+.
113-
assert bulkhead._sem._value == max_concurrent # noqa: SLF001
111+
# Behavioral drain check: after gather completes, max_concurrent fresh acquires
112+
# must succeed simultaneously under a tight acquire_timeout. If any slot leaked,
113+
# the post-drain acquires would block past the timeout and BulkheadFullError
114+
# would surface — a deterministic failure rather than an internals-peek.
115+
bulkhead._acquire_timeout = 0.05 # noqa: SLF001 — test-local override of internal config
116+
await asyncio.gather(*(client.get(f"https://example.test/post-drain-{i}") for i in range(max_concurrent)))

‎tests/test_bulkhead_sync_props.py‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Hypothesis property tests for sync Bulkhead.
2+
3+
Mirrors tests/test_bulkhead_props.py for sync/async parity. Uses
4+
threading.Thread + a shared lock-guarded counter instead of asyncio.gather.
5+
6+
Properties verified:
7+
1. Observed in-flight count never exceeds max_concurrent under any interleaving.
8+
2. With acquire_timeout=0 and a full bulkhead, the call raises BulkheadFullError.
9+
3. Successful acquisitions are released — after drain, max_concurrent fresh
10+
acquires succeed (behavioral, no internal-state peek).
11+
"""
12+
13+
import threading
14+
import time
15+
from concurrent.futures import ThreadPoolExecutor
16+
from http import HTTPStatus
17+
18+
import httpx2
19+
import pytest
20+
from hypothesis import given, settings
21+
from hypothesis import strategies as st
22+
23+
from httpware import Client
24+
from httpware.errors import BulkheadFullError
25+
from httpware.middleware.resilience.bulkhead import Bulkhead
26+
27+
28+
class _InFlightHandler:
29+
"""Tracks max simultaneous in-flight count under a threading.Lock."""
30+
31+
def __init__(self, delay: float) -> None:
32+
self.delay = delay
33+
self._lock = threading.Lock()
34+
self.in_flight = 0
35+
self.max_in_flight = 0
36+
self.calls = 0
37+
38+
def __call__(self, request: httpx2.Request) -> httpx2.Response:
39+
with self._lock:
40+
self.calls += 1
41+
self.in_flight += 1
42+
self.max_in_flight = max(self.max_in_flight, self.in_flight)
43+
try:
44+
time.sleep(self.delay)
45+
return httpx2.Response(HTTPStatus.OK, request=request)
46+
finally:
47+
with self._lock:
48+
self.in_flight -= 1
49+
50+
51+
@given(
52+
max_concurrent=st.integers(min_value=1, max_value=8),
53+
n_requests=st.integers(min_value=1, max_value=32),
54+
delay=st.floats(min_value=0.001, max_value=0.005),
55+
)
56+
@settings(max_examples=20, deadline=None)
57+
def test_in_flight_never_exceeds_max_concurrent(
58+
max_concurrent: int,
59+
n_requests: int,
60+
delay: float,
61+
) -> None:
62+
handler = _InFlightHandler(delay=delay)
63+
transport = httpx2.MockTransport(handler)
64+
client = Client(
65+
httpx2_client=httpx2.Client(transport=transport),
66+
middleware=[Bulkhead(max_concurrent=max_concurrent, acquire_timeout=None)],
67+
)
68+
with ThreadPoolExecutor(max_workers=n_requests) as pool:
69+
futures = [pool.submit(client.get, f"https://example.test/{i}") for i in range(n_requests)]
70+
for f in futures:
71+
f.result()
72+
assert handler.calls == n_requests
73+
assert handler.max_in_flight <= max_concurrent
74+
75+
76+
@given(
77+
max_concurrent=st.integers(min_value=1, max_value=4),
78+
extra_requests=st.integers(min_value=1, max_value=8),
79+
)
80+
@settings(max_examples=15, deadline=None)
81+
def test_fail_fast_rejects_when_at_capacity(
82+
max_concurrent: int,
83+
extra_requests: int,
84+
) -> None:
85+
handler = _InFlightHandler(delay=0.05) # hold slots long enough for fail-fast to fire
86+
transport = httpx2.MockTransport(handler)
87+
client = Client(
88+
httpx2_client=httpx2.Client(transport=transport),
89+
middleware=[Bulkhead(max_concurrent=max_concurrent, acquire_timeout=0)],
90+
)
91+
92+
# Fill the bulkhead with max_concurrent long-running threads.
93+
pool = ThreadPoolExecutor(max_workers=max_concurrent + extra_requests)
94+
holders = [pool.submit(client.get, f"https://example.test/hold-{i}") for i in range(max_concurrent)]
95+
# Wait for the holders to acquire — sleep long enough for thread startup.
96+
time.sleep(0.005)
97+
98+
# Any extra requests should fail fast with BulkheadFullError.
99+
for i in range(extra_requests):
100+
with pytest.raises(BulkheadFullError):
101+
client.get(f"https://example.test/extra-{i}")
102+
103+
# Cleanup the holders.
104+
for f in holders:
105+
f.result()
106+
pool.shutdown()
107+
108+
109+
@given(
110+
max_concurrent=st.integers(min_value=1, max_value=4),
111+
n_requests=st.integers(min_value=4, max_value=16),
112+
)
113+
@settings(max_examples=15, deadline=None)
114+
def test_no_slot_leak_after_drain(max_concurrent: int, n_requests: int) -> None:
115+
"""After all threads complete, the bulkhead has its full capacity available."""
116+
handler = _InFlightHandler(delay=0.001)
117+
bulkhead = Bulkhead(max_concurrent=max_concurrent, acquire_timeout=None)
118+
transport = httpx2.MockTransport(handler)
119+
client = Client(
120+
httpx2_client=httpx2.Client(transport=transport),
121+
middleware=[bulkhead],
122+
)
123+
124+
with ThreadPoolExecutor(max_workers=n_requests) as pool:
125+
futures = [pool.submit(client.get, f"https://example.test/{i}") for i in range(n_requests)]
126+
for f in futures:
127+
f.result()
128+
129+
# Behavioral drain check: after the threads finish, max_concurrent fresh
130+
# acquires must succeed simultaneously under a tight acquire_timeout. If
131+
# any slot leaked, the post-drain acquires would block past the timeout.
132+
bulkhead._acquire_timeout = 0.05 # noqa: SLF001 — test-local override
133+
with ThreadPoolExecutor(max_workers=max_concurrent) as pool:
134+
post = [pool.submit(client.get, f"https://example.test/post-drain-{i}") for i in range(max_concurrent)]
135+
for f in post:
136+
f.result()

‎tests/test_middleware_sync.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,46 @@ def my_handler(request: httpx2.Request, exc: Exception) -> httpx2.Response | Non
156156

157157
assert "on_error" in repr(my_handler)
158158
assert "my_handler" in repr(my_handler)
159+
160+
161+
def test_on_error_lets_keyboardinterrupt_propagate() -> None:
162+
"""on_error catches Exception, NOT BaseException — KeyboardInterrupt must escape.
163+
164+
Sync mirror of test_middleware.py::test_on_error_lets_cancelled_propagate. The
165+
async test pins asyncio.CancelledError (a BaseException). The sync world's
166+
equivalents are KeyboardInterrupt and SystemExit; they too must propagate.
167+
"""
168+
169+
@on_error
170+
def swallow_all(
171+
request: httpx2.Request, # noqa: ARG001
172+
exc: Exception, # noqa: ARG001
173+
) -> httpx2.Response | None: # pragma: no cover
174+
msg = "should not catch BaseException"
175+
raise AssertionError(msg)
176+
177+
def terminal_ki(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
178+
raise KeyboardInterrupt
179+
180+
dispatch = compose((swallow_all,), terminal_ki)
181+
with pytest.raises(KeyboardInterrupt):
182+
dispatch(_make_request())
183+
184+
185+
def test_on_error_lets_systemexit_propagate() -> None:
186+
"""SystemExit (sibling of KeyboardInterrupt) must also escape on_error."""
187+
188+
@on_error
189+
def swallow_all(
190+
request: httpx2.Request, # noqa: ARG001
191+
exc: Exception, # noqa: ARG001
192+
) -> httpx2.Response | None: # pragma: no cover
193+
msg = "should not catch BaseException"
194+
raise AssertionError(msg)
195+
196+
def terminal_se(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001
197+
raise SystemExit
198+
199+
dispatch = compose((swallow_all,), terminal_se)
200+
with pytest.raises(SystemExit):
201+
dispatch(_make_request())

‎tests/test_optional_extras_pydantic_missing.py‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
from httpware.decoders.pydantic import PydanticDecoder
1515

1616

17+
class _FakeDecoder:
18+
"""Test stand-in for ResponseDecoder; never called at runtime."""
19+
20+
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — name pinned by ResponseDecoder protocol
21+
return model() # pragma: no cover
22+
23+
1724
def test_pydantic_decoder_init_raises_when_pydantic_missing() -> None:
1825
with (
1926
patch("httpware._internal.import_checker.is_pydantic_installed", False),
@@ -39,12 +46,16 @@ def test_sync_client_default_decoder_raises_when_pydantic_missing() -> None:
3946

4047

4148
def test_async_client_accepts_explicit_decoder_without_pydantic() -> None:
42-
"""An explicit decoder= escapes the fail-fast even when pydantic is 'missing'."""
49+
"""An explicit decoder= escapes the fail-fast AND is actually wired to the client."""
50+
fake = _FakeDecoder()
51+
with patch("httpware._internal.import_checker.is_pydantic_installed", False):
52+
client = AsyncClient(decoder=fake)
53+
assert client._decoder is fake # noqa: SLF001 — wired the explicit decoder, not a default
4354

44-
class _FakeDecoder:
45-
def decode(self, content: bytes, model: type) -> object: # noqa: ARG002 — name pinned by ResponseDecoder protocol
46-
return model() # pragma: no cover
4755

56+
def test_sync_client_accepts_explicit_decoder_without_pydantic() -> None:
57+
"""Sync mirror: explicit decoder= escapes the fail-fast AND is wired for sync Client too."""
58+
fake = _FakeDecoder()
4859
with patch("httpware._internal.import_checker.is_pydantic_installed", False):
49-
client = AsyncClient(decoder=_FakeDecoder())
50-
assert client is not None
60+
client = Client(decoder=fake)
61+
assert client._decoder is fake # noqa: SLF001 — wired the explicit decoder, not a default

‎tests/test_threading_with_shared_budget.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,12 @@ def test_shared_budget_across_sync_threads_and_async_loop() -> None:
7272
t.join()
7373

7474
# The lock kept the budget's internal deques consistent — no IndexError, no corruption.
75-
# No specific count assertion: the test passes if it completes without an exception
76-
# from the budget itself. Add a smoke check that the budget recorded SOME activity:
77-
assert len(budget._deposits) > 0 # noqa: SLF001
75+
# 0.8.3 deposit-hoist: deposits count requests, not attempts (one per __call__,
76+
# regardless of max_attempts). Budget TTL is 60.0 so no purge fires during the
77+
# sub-second runtime; the count is exact.
78+
expected_deposits = (_N_SYNC_THREADS * _N_OPS_PER_THREAD) + _N_ASYNC_TASKS
79+
assert len(budget._deposits) == expected_deposits, ( # noqa: SLF001
80+
f"expected {expected_deposits} deposits, got {len(budget._deposits)}" # noqa: SLF001
81+
)
7882

7983
sync_client.close()

0 commit comments

Comments
 (0)