|
| 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() |
0 commit comments