From ece7907731cc025ceb4bdd197272abe3ce4583ef Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 08:48:27 +0300 Subject: [PATCH 01/13] docs(planning): add nogil free-threading support design Full-lane design for certifying httpware under free-threaded CPython (PEP 703): verify-first sequencing, free-threaded CI on 3.14t (full suite + all extras; 3.13t deferred on the msgspec cp313t wheel gap), parallel stress tests for the Lock-based resilience components + the shared httpx2 pool, a committed contention benchmark, and the Free Threading classifier added last. Backed by empirical findings: httpx2 2.5.0 passes a 32-thread shared-pool stress run under nogil, and the extras wheel matrix (pydantic/msgspec/otel) is verified per interpreter. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-18.01-nogil-free-threading-support.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 planning/changes/2026-07-18.01-nogil-free-threading-support.md diff --git a/planning/changes/2026-07-18.01-nogil-free-threading-support.md b/planning/changes/2026-07-18.01-nogil-free-threading-support.md new file mode 100644 index 0000000..1ec891b --- /dev/null +++ b/planning/changes/2026-07-18.01-nogil-free-threading-support.md @@ -0,0 +1,95 @@ +--- +summary: Certify httpware under free-threaded CPython (PEP 703) — free-threaded CI on 3.14t, parallel stress tests, a contention benchmark, and the Free Threading classifier (added last). +--- + +# Design: Free-threading (nogil) support + +## Summary + +Certify that httpware runs correctly under free-threaded CPython (PEP 703), +backed by evidence rather than a bare classifier. Adds a free-threaded CI job +(3.14t, full suite + all extras), parallel stress tests for the Lock-based +resilience components and the shared httpx2 connection pool, a committed +contention benchmark with a recorded baseline, and — last, once everything is +green — the `Programming Language :: Python :: Free Threading` classifier. +Verify-first: the public claim trails the proof. + +## Motivation + +httpware is a concurrency library: its resilience suite (`RetryBudget`, +`CircuitBreaker`, `Bulkhead`) is built on `threading.Lock`/`Semaphore` and +documents thread-safety invariants, and a shared client across threads is a +documented usage pattern. Free-threaded CPython (free-threaded builds available +since 3.13, officially supported in 3.14) removes the GIL that currently masks +any latent races. The library already ships 3.13/3.14 classifiers and tests +those *GIL* builds, but nothing exercises true thread parallelism. + +Two empirical findings (2026-07-18) de-risk the work: + +- **httpx2 2.5.0 holds under free-threading.** 32 threads sharing one client + + connection pool, 3 × 12,800 requests on 3.14t with the GIL disabled, each + response verified against its request: zero cross-talk, zero crashes. +- **Extras wheel matrix on free-threaded interpreters.** pydantic ✓ (3.13t + + 3.14t), msgspec ✓ 3.14t but ✗ no cp313t wheel (0.18–0.21.1), otel pure-Python + ✓. **3.14t is the only interpreter with complete extras coverage from + prebuilt wheels;** all three keep the GIL disabled after import. + +## Design + +Five workstreams, landed verify-first (classifier last): + +1. **CI enablement.** Add one free-threaded job on `3.14t` to + `.github/workflows/_checks.yml`, running the full suite + all extras (uv + installs `3.14t`; extras resolve from prebuilt free-threaded wheels). The + existing GIL matrix (3.11–3.14) is unchanged. 3.13t-free-threaded is deferred + until msgspec ships a cp313t wheel (record in `deferred.md`). +2. **Free-threaded audit + stress tests.** Audit httpware's own lock usage under + parallelism, then add stress tests (real threads via `ThreadPoolExecutor`, + shared instances, high iteration counts, invariant assertions) for + `RetryBudget` token accounting, `CircuitBreaker` transitions, `Bulkhead` + semaphore counting, and the event-loop guard's cross-loop rejection. TDD per + component. **Specific target:** `_event_loop_guard.py`'s `# pragma: no cover` + double-checked-locking arm is annotated "single-threaded tests can't + trigger" — free-threading makes it reachable, so the pragma is re-examined + (covered → removed, or reworded). +3. **httpx2 boundary test.** Promote the shared-pool cross-talk stress harness + into the suite (marked) as living regression evidence for the one dependency + httpware can't self-certify. +4. **Benchmarks.** A committed `benchmarks/` script measuring contended + throughput (GIL vs free-threaded) on the shared Lock components — does nogil + help, or does lock contention eat the gain? Baseline numbers recorded under + `planning/audits/`. +5. **Certify + promote.** Add the `Free Threading` classifier; promote into + `architecture/resilience.md` (thread-safety prose), `architecture/testing.md` + (stress-test convention + marker), `architecture/overview.md`; release notes. + +Stress tests carry a `stress` pytest marker so the free-threaded job runs them; +they exercise concurrency under the GIL too but only *prove* safety on 3.14t. + +## Non-goals + +- **3.13t support** — blocked on a msgspec cp313t wheel; deferred, not designed out. +- **Certifying httpx2 itself** — out of our control; we ship evidence, optionally upstream later. +- **CI perf regression gates** — benchmarks are characterization (committed script + recorded baseline), not a gate; shared-runner perf is too noisy. +- **Reworking the Lock-based design** — the audit validates it; it does not rewrite it. + +## Testing + +- Full suite green under `uv run --python 3.14t` locally and in the new CI job, + GIL disabled (a conftest guard asserts `sys._is_gil_enabled() is False` in + that job). +- `pytest -m stress` on 3.14t: RetryBudget / breaker / bulkhead / guard + invariants hold across many-thread runs; the httpx2 boundary test shows no + cross-talk. +- `just lint-ci` and the planning validator unchanged and green. + +## Risk + +- **msgspec 3.13t wheel gap (med likelihood / low impact).** Scopes free-threaded + CI to 3.14t only. Mitigation: documented deferral; revisit on wheel availability. +- **Flaky stress tests (med / med).** Probabilistic race detection. Mitigation: + invariant assertions (not interleaving-dependent), tuned iteration counts, the + `stress` marker isolates them from the default run. +- **Audit surfaces a real race (low / high).** The 2026-06-14 deep audit already + reasoned the loop-guard fast path is benign; a real race would be a genuine bug + caught *before* shipping the claim — which is the point of verify-first. From 8f25994cb130429803208637b73d8787e9742107 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 10:25:31 +0300 Subject: [PATCH 02/13] test: register stress marker and omit benchmarks/ from coverage --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 66e1337..d0b5b76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,11 @@ addopts = "--cov=. --cov-report term-missing --cov-fail-under=100" asyncio_mode = "auto" pythonpath = ["src"] asyncio_default_fixture_loop_scope = "function" +markers = [ + "stress: concurrency stress test; proves thread-safety under free-threaded CPython (3.14t). Runs under the GIL too, so it counts toward coverage.", +] [tool.coverage] run.concurrency = ["thread"] +run.omit = ["benchmarks/*"] report.exclude_also = ["if typing.TYPE_CHECKING:", 'pytest\.fail\('] From e2fe73598c2416d4134f73b1a74f9ef2712c5c31 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 10:31:42 +0300 Subject: [PATCH 03/13] test(resilience): free-threaded stress test for shared RetryBudget --- .../test_retry_budget_freethreaded_stress.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_retry_budget_freethreaded_stress.py diff --git a/tests/test_retry_budget_freethreaded_stress.py b/tests/test_retry_budget_freethreaded_stress.py new file mode 100644 index 0000000..9ff9f93 --- /dev/null +++ b/tests/test_retry_budget_freethreaded_stress.py @@ -0,0 +1,53 @@ +"""Free-threaded stress: a shared RetryBudget stays consistent under real thread parallelism. + +Meaningful under free-threaded CPython (3.14t), where threads run Python in parallel; also a +valid concurrency check under the GIL. Verify-first: this is expected to PASS (RetryBudget is +already lock-guarded). A failure means a real race. +""" + +import contextlib +import threading +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import Client, Retry +from httpware.middleware.resilience.budget import RetryBudget + +_N_THREADS = 16 +_N_OPS = 100 + + +def _fail(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) + + +def _fixed_clock() -> float: + return 0.0 + + +@pytest.mark.stress +def test_shared_retry_budget_survives_thread_parallelism() -> None: + # Pinned clock: all deposits share timestamp 0.0, so _purge (strict `< cutoff`) evicts + # nothing and the deposit count is exact — any lost/torn append would change it. + budget = RetryBudget(ttl=60.0, min_retries_per_sec=1000.0, percent_can_retry=0.5, _now=_fixed_clock) + client = Client( + httpx2_client=httpx2.Client(transport=httpx2.MockTransport(_fail)), + middleware=[Retry(budget=budget, max_attempts=2, base_delay=0.0001, max_delay=0.001)], + ) + + def worker() -> None: + for _ in range(_N_OPS): + with contextlib.suppress(Exception): + client.get("https://example.test/x") + + threads = [threading.Thread(target=worker) for _ in range(_N_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + client.close() + + # One deposit per request (deposit-hoist counts requests, not attempts). + assert len(budget._deposits) == _N_THREADS * _N_OPS # noqa: SLF001 From b58a2a6263067092fc86564fcc704c5aee7bf232 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 10:37:00 +0300 Subject: [PATCH 04/13] test(resilience): free-threaded stress test for Bulkhead concurrency cap --- tests/test_bulkhead_freethreaded_stress.py | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_bulkhead_freethreaded_stress.py diff --git a/tests/test_bulkhead_freethreaded_stress.py b/tests/test_bulkhead_freethreaded_stress.py new file mode 100644 index 0000000..115e258 --- /dev/null +++ b/tests/test_bulkhead_freethreaded_stress.py @@ -0,0 +1,53 @@ +"""Free-threaded stress: Bulkhead's semaphore caps real parallel in-flight requests. + +The handler tracks live concurrency; peak must never exceed max_concurrent even when many +threads run Python in parallel (3.14t). Verify-first: expected to PASS. +""" + +import threading +import time +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import Bulkhead, Client + +_MAX = 4 +_N_THREADS = 24 + + +@pytest.mark.stress +def test_bulkhead_never_exceeds_max_concurrent_under_parallelism() -> None: + active = 0 + peak = 0 + guard = threading.Lock() + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal active, peak + with guard: + active += 1 + peak = max(peak, active) + time.sleep(0.002) # hold the slot so contention is real + with guard: + active -= 1 + return httpx2.Response(HTTPStatus.OK, request=request) + + client = Client( + httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler)), + # generous acquire_timeout so contention blocks rather than raising BulkheadFullError + middleware=[Bulkhead(max_concurrent=_MAX, acquire_timeout=30.0)], + ) + + def worker() -> None: + client.get("https://example.test/x") + + threads = [threading.Thread(target=worker) for _ in range(_N_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + client.close() + + assert peak <= _MAX # the invariant: semaphore holds the cap under true parallelism + assert peak > 1 # sanity: the test actually exercised concurrency From aa893881955d9f589be83a042b54d7a101c386b5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 10:44:27 +0300 Subject: [PATCH 05/13] test(resilience): free-threaded stress test for CircuitBreaker state --- ...est_circuit_breaker_freethreaded_stress.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_circuit_breaker_freethreaded_stress.py diff --git a/tests/test_circuit_breaker_freethreaded_stress.py b/tests/test_circuit_breaker_freethreaded_stress.py new file mode 100644 index 0000000..cb370a1 --- /dev/null +++ b/tests/test_circuit_breaker_freethreaded_stress.py @@ -0,0 +1,59 @@ +"""Free-threaded stress: CircuitBreaker transitions stay consistent under parallel failures. + +Many threads drive concurrent 5xx requests. Every call must raise an expected error type — a +StatusError while the request is forwarded, or CircuitOpenError once the breaker fast-fails — +and the breaker must end OPEN with no torn state. Verify-first: expected to PASS. + +Collect *every* exception (not just unexpected ones) so the single `except` branch always runs +and stays covered under the 100% gate; then assert all collected errors are of the expected +types. A catch-all that only fires on the unexpected path would be dead code on a passing run +and would drop coverage below 100%. +""" + +import threading +from http import HTTPStatus + +import httpx2 +import pytest + +from httpware import CircuitBreaker, CircuitState, Client +from httpware.errors import CircuitOpenError, StatusError + +_N_THREADS = 16 +_N_OPS = 50 + + +def _fail(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.INTERNAL_SERVER_ERROR, request=request) + + +@pytest.mark.stress +def test_circuit_breaker_opens_consistently_under_parallel_failures() -> None: + breaker = CircuitBreaker(failure_threshold=5, reset_timeout=60.0) + client = Client( + httpx2_client=httpx2.Client(transport=httpx2.MockTransport(_fail)), + middleware=[breaker], + ) + errors: list[Exception] = [] + guard = threading.Lock() + + def worker() -> None: + for _ in range(_N_OPS): + try: + client.get("https://example.test/x") + except Exception as exc: # noqa: BLE001 + with guard: + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(_N_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + client.close() + + # Every request fails: a 5xx StatusError while forwarding, or CircuitOpenError once open. + # No torn state means no other type leaks and the count is exact. + assert len(errors) == _N_THREADS * _N_OPS + assert all(isinstance(exc, (StatusError, CircuitOpenError)) for exc in errors) + assert breaker.state is CircuitState.OPEN From 52a636bc3f669a35a264a049a71a24c39c073e54 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 10:52:52 +0300 Subject: [PATCH 06/13] test(resilience): cover cross-loop rejection; note free-threaded reachability of guard race arm --- .../resilience/_event_loop_guard.py | 6 ++-- tests/test_event_loop_guard_freethreaded.py | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 tests/test_event_loop_guard_freethreaded.py diff --git a/src/httpware/middleware/resilience/_event_loop_guard.py b/src/httpware/middleware/resilience/_event_loop_guard.py index 043ea4d..e344330 100644 --- a/src/httpware/middleware/resilience/_event_loop_guard.py +++ b/src/httpware/middleware/resilience/_event_loop_guard.py @@ -30,8 +30,8 @@ def check_event_loop( cached = get_loop() if cached is None: set_loop(current) - # pragma below: inner double-check-with-lock race arm; only reachable when - # two threads simultaneously pass the outer check, which single-threaded - # tests can't trigger. + # pragma below: inner double-check-with-lock race arm. Reachable only when two + # threads pass the outer check and race for loop_lock — free-threaded CPython can + # reach it, but only nondeterministically, so it stays excluded from coverage. elif cached is not current: # pragma: no cover raise RuntimeError(message_template.format(first=cached, current=current)) diff --git a/tests/test_event_loop_guard_freethreaded.py b/tests/test_event_loop_guard_freethreaded.py new file mode 100644 index 0000000..74b2118 --- /dev/null +++ b/tests/test_event_loop_guard_freethreaded.py @@ -0,0 +1,35 @@ +"""An async resilience middleware binds to its first event loop; a second loop is rejected. + +This deterministically exercises the guard's outer cross-loop raise (the reachable arm). The +inner double-checked-lock arm stays `# pragma: no cover`: it needs two threads to bind the loop +simultaneously, which free-threading can reach but only nondeterministically. +""" + +from http import HTTPStatus + +import asyncio + +import httpx2 +import pytest + +from httpware import AsyncBulkhead, AsyncClient + + +def _ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(HTTPStatus.OK, request=request) + + +async def _drive(bulkhead: AsyncBulkhead) -> None: + client = AsyncClient( + httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(_ok)), + middleware=[bulkhead], + ) + async with client: + await client.get("https://example.test/x") + + +def test_async_bulkhead_rejects_second_event_loop() -> None: + bulkhead = AsyncBulkhead(max_concurrent=2) + asyncio.run(_drive(bulkhead)) # binds to loop #1 + with pytest.raises(RuntimeError, match="single event loop"): + asyncio.run(_drive(bulkhead)) # loop #2 -> rejected From 0b4899dc10c2d91e0b4686897ecf3b0bb1426c9b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 10:58:17 +0300 Subject: [PATCH 07/13] test: free-threaded boundary stress for httpx2 shared connection pool --- tests/test_httpx2_freethreaded_boundary.py | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_httpx2_freethreaded_boundary.py diff --git a/tests/test_httpx2_freethreaded_boundary.py b/tests/test_httpx2_freethreaded_boundary.py new file mode 100644 index 0000000..7ef8820 --- /dev/null +++ b/tests/test_httpx2_freethreaded_boundary.py @@ -0,0 +1,57 @@ +"""Boundary evidence: httpx2's shared connection pool holds under free-threaded parallelism. + +httpware can't self-certify httpx2, so this is living regression evidence. Each request is +verified against its response to catch pool cross-talk. Uses a real loopback server because a +mock transport bypasses the pool this test exists to stress. +""" + +import threading +from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import httpx2 +import pytest + +_N_THREADS = 16 +_N_REQ = 100 + + +class _Echo(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = self.path.rsplit("/", 1)[-1].encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args: object) -> None: # silence server logs + pass + + +@pytest.mark.stress +def test_httpx2_shared_pool_no_crosstalk_under_parallelism() -> None: + server = ThreadingHTTPServer(("127.0.0.1", 0), _Echo) + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + results: list[tuple[int, int, str]] = [] + guard = threading.Lock() + + client = httpx2.Client(base_url=f"http://127.0.0.1:{port}", timeout=10.0) + + def worker(tid: int) -> None: + for i in range(_N_REQ): + n = tid * _N_REQ + i + r = client.get(f"/echo/{n}") + with guard: # runs every request, so the recording stays covered + results.append((n, r.status_code, r.text)) + + try: + with ThreadPoolExecutor(max_workers=_N_THREADS) as ex: + list(ex.map(worker, range(_N_THREADS))) + finally: + client.close() + server.shutdown() + + # Every response must echo its own request's number; a mismatch is pool cross-talk. + mismatches = [(n, status, text) for n, status, text in results if status != 200 or text != str(n)] + assert mismatches == [] From cacbb19e7693256f97cb7de1ed57dfe5610fafa6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 11:07:09 +0300 Subject: [PATCH 08/13] ci: add free-threaded (3.14t) pytest job running the full suite --- .github/workflows/_checks.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 0fd0b7f..bb0ee51 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -45,3 +45,18 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - run: just test --cov-report xml + + pytest-freethreaded: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: extractions/setup-just@v4 + - uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install 3.14t + - run: uv python pin 3.14t + - run: just install + - run: uv run --no-sync python -c "import sys; assert not sys._is_gil_enabled(), 'GIL is enabled'" + - run: just test --cov-report xml From 994e7f91ad17e16dbcd47815a4c5fa96e10329df Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 11:21:54 +0300 Subject: [PATCH 09/13] bench: contention benchmark for shared resilience components + free-threading baseline Adds benchmarks/contention.py, measuring shared-RetryBudget throughput under real thread parallelism (GIL 3.11 vs free-threaded 3.14t); not a CI gate, characterization only. Records the baseline numbers plus the project's free-threading findings (extras wheel matrix, httpx2 shared-pool boundary, stress-test coverage) in planning/audits/2026-07-18-free-threading-audit.md. --- benchmarks/__init__.py | 0 benchmarks/contention.py | 54 ++++++++++++ .../audits/2026-07-18-free-threading-audit.md | 87 +++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/contention.py create mode 100644 planning/audits/2026-07-18-free-threading-audit.md diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/contention.py b/benchmarks/contention.py new file mode 100644 index 0000000..eace8d8 --- /dev/null +++ b/benchmarks/contention.py @@ -0,0 +1,54 @@ +"""Contention benchmark: shared Lock-based resilience components, GIL vs free-threaded. + +Answers whether removing the GIL helps httpware's shared components or whether lock contention +eats the gain. Run under both interpreters and compare: + + uv run --python 3.11 benchmarks/contention.py + uv run --python 3.14t benchmarks/contention.py + +Not a test and not a CI gate (shared-runner perf is too noisy); a characterization tool with a +recorded baseline in planning/audits/2026-07-18-free-threading-audit.md. +""" + +import sys +import threading +import time + +from httpware.middleware.resilience.budget import RetryBudget + + +_N_THREADS = 8 +_N_OPS = 200_000 + + +def _run() -> float: + budget = RetryBudget(ttl=60.0, min_retries_per_sec=1_000_000.0, percent_can_retry=1.0) + + def worker() -> None: + for _ in range(_N_OPS): + budget.deposit() + budget.try_withdraw() + + threads = [threading.Thread(target=worker) for _ in range(_N_THREADS)] + start = time.perf_counter() + for t in threads: + t.start() + for t in threads: + t.join() + return time.perf_counter() - start + + +def main() -> None: + gil = getattr(sys, "_is_gil_enabled", lambda: True)() + elapsed = _run() + ops = _N_THREADS * _N_OPS * 2 + message = ( + f"python={sys.version.split()[0]} gil_enabled={gil} threads={_N_THREADS} " + f"ops={ops} elapsed={elapsed:.3f}s throughput={ops / elapsed:,.0f} ops/s" + ) + # T201 is fine here: benchmarks/ is not library code and is coverage-omitted. + print(message) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/planning/audits/2026-07-18-free-threading-audit.md b/planning/audits/2026-07-18-free-threading-audit.md new file mode 100644 index 0000000..4f3c309 --- /dev/null +++ b/planning/audits/2026-07-18-free-threading-audit.md @@ -0,0 +1,87 @@ +# httpware free-threading (nogil) audit — 2026-07-18 + +**Status:** complete +**Scope:** empirical findings backing `planning/changes/2026-07-18.01-nogil-free-threading-support.md` +— the extras wheel matrix, the httpx2 shared-pool boundary result, the +free-threaded stress-test suite, and the contention benchmark baseline +(`benchmarks/contention.py`). + +## Extras wheel matrix (free-threaded interpreters) + +| Extra | 3.13t | 3.14t | +|---|---|---| +| pydantic | ✓ | ✓ | +| msgspec | ✗ (no cp313t wheel, versions 0.18–0.21.1) | ✓ | +| otel (opentelemetry) | ✓ (pure-Python, no wheel gap) | ✓ | + +3.14t is the only free-threaded interpreter with complete extras coverage +from prebuilt wheels; all three extras keep the GIL disabled after import +(verified — none silently re-enables it). This is why free-threaded CI +(`.github/workflows/_checks.yml`, `pytest-freethreaded` job) targets `3.14t` +only; 3.13t is deferred in `planning/deferred.md` until msgspec ships a +cp313t wheel. + +## httpx2 shared-pool boundary + +`tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism` +(marked `stress`) drives 32 threads sharing one `httpx2` client + connection +pool, 3 × 12,800 requests, on 3.14t with the GIL disabled. Every response is +verified against its own request. Result: **zero cross-talk, zero crashes.** +httpx2 is a dependency httpware can't self-certify beyond this boundary test — +this is the recorded regression evidence that it holds under true thread +parallelism. + +## Free-threaded stress-test suite + +Five tests (four marked `pytest.mark.stress`, one deterministic cross-loop +test) exercise real thread parallelism against httpware's Lock/Semaphore-based +components. All five pass on 3.14t with `sys._is_gil_enabled() is False` +(verified directly, and via the `pytest-freethreaded` CI job which asserts the +GIL is disabled before running the suite): + +- `tests/test_retry_budget_freethreaded_stress.py::test_shared_retry_budget_survives_thread_parallelism` +- `tests/test_circuit_breaker_freethreaded_stress.py::test_circuit_breaker_opens_consistently_under_parallel_failures` +- `tests/test_bulkhead_freethreaded_stress.py::test_bulkhead_never_exceeds_max_concurrent_under_parallelism` +- `tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism` +- `tests/test_event_loop_guard_freethreaded.py::test_async_bulkhead_rejects_second_event_loop` + (deterministically covers the guard's outer cross-loop raise; the inner + double-checked-lock arm stays `# pragma: no cover` — free-threading makes it + reachable but only nondeterministically, so it is not asserted here) + +Each stress test uses invariant assertions (final counts, exhaustion bounds, +state consistency) rather than interleaving-dependent timing, per +`architecture/testing.md`'s stress-test convention. + +## Contention benchmark: GIL vs free-threaded + +`benchmarks/contention.py` (committed, not a CI gate — shared-runner perf is +too noisy to gate on) drives 8 threads × 200,000 iterations of +`RetryBudget.deposit()` + `RetryBudget.try_withdraw()` against one shared +`RetryBudget` (3,200,000 total lock-guarded ops), measuring wall-clock +throughput. Run twice per interpreter for stability: + +``` +uv run --no-sync python benchmarks/contention.py +python=3.11.9 gil_enabled=True threads=8 ops=3200000 elapsed=0.869s throughput=3,684,320 ops/s +python=3.11.9 gil_enabled=True threads=8 ops=3200000 elapsed=0.869s throughput=3,683,289 ops/s + +/bin/python benchmarks/contention.py +python=3.14.6 gil_enabled=False threads=8 ops=3200000 elapsed=1.626s throughput=1,967,685 ops/s +python=3.14.6 gil_enabled=False threads=8 ops=3200000 elapsed=1.634s throughput=1,958,734 ops/s +``` + +**Interpretation:** for this workload — a tiny critical section +(`deque` purge + append/compare under `threading.Lock`) hammered by 8 threads +with no non-lock work between acquisitions — free-threaded 3.14t is +**~1.9x slower** than GIL 3.11 (≈1.96M ops/s vs ≈3.68M ops/s), so **lock +contention dominates and nogil does not help here**: free threads spend more +wall-clock time contending for the same `threading.Lock` than the GIL's +cooperative bytecode-level serialization costs, and `RetryBudget`'s +correctness (proven by the stress test above) does not translate into a +throughput win under this access pattern. This is expected for a +single-shared-lock hot loop and is not a regression to fix — a real client +workload interleaves this critical section with I/O and non-lock CPU work +where free-threading's benefit (true parallel non-lock work) would show up +where this microbenchmark cannot show it. `RetryBudget`'s thread-safety +contract (`architecture/resilience.md`) is unaffected either way; this +benchmark characterizes throughput, not correctness. From 91dccbd98815838ee79904ec4f901be304a781ea Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 11:22:01 +0300 Subject: [PATCH 10/13] fix(lint): resolve ruff/ty drift in free-threaded stress tests Ruff and ty are unpinned in pyproject and floated forward since Tasks 2-6 landed: isort now orders these files' imports differently, PLR2004 flags the 200 status literal, and ty's stricter override check flags log_message's narrowed signature. Mechanical fixes only (import order, a named status constant, a stdlib-matching signature) -- no behavior change, confirmed by the full suite still passing at 100% coverage. --- tests/test_bulkhead_freethreaded_stress.py | 1 + tests/test_circuit_breaker_freethreaded_stress.py | 1 + tests/test_event_loop_guard_freethreaded.py | 3 +-- tests/test_httpx2_freethreaded_boundary.py | 6 ++++-- tests/test_retry_budget_freethreaded_stress.py | 1 + 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_bulkhead_freethreaded_stress.py b/tests/test_bulkhead_freethreaded_stress.py index 115e258..1453bb6 100644 --- a/tests/test_bulkhead_freethreaded_stress.py +++ b/tests/test_bulkhead_freethreaded_stress.py @@ -13,6 +13,7 @@ from httpware import Bulkhead, Client + _MAX = 4 _N_THREADS = 24 diff --git a/tests/test_circuit_breaker_freethreaded_stress.py b/tests/test_circuit_breaker_freethreaded_stress.py index cb370a1..4a7de88 100644 --- a/tests/test_circuit_breaker_freethreaded_stress.py +++ b/tests/test_circuit_breaker_freethreaded_stress.py @@ -19,6 +19,7 @@ from httpware import CircuitBreaker, CircuitState, Client from httpware.errors import CircuitOpenError, StatusError + _N_THREADS = 16 _N_OPS = 50 diff --git a/tests/test_event_loop_guard_freethreaded.py b/tests/test_event_loop_guard_freethreaded.py index 74b2118..6de09b0 100644 --- a/tests/test_event_loop_guard_freethreaded.py +++ b/tests/test_event_loop_guard_freethreaded.py @@ -5,9 +5,8 @@ simultaneously, which free-threading can reach but only nondeterministically. """ -from http import HTTPStatus - import asyncio +from http import HTTPStatus import httpx2 import pytest diff --git a/tests/test_httpx2_freethreaded_boundary.py b/tests/test_httpx2_freethreaded_boundary.py index 7ef8820..7d45029 100644 --- a/tests/test_httpx2_freethreaded_boundary.py +++ b/tests/test_httpx2_freethreaded_boundary.py @@ -12,8 +12,10 @@ import httpx2 import pytest + _N_THREADS = 16 _N_REQ = 100 +_HTTP_OK = 200 class _Echo(BaseHTTPRequestHandler): @@ -24,7 +26,7 @@ def do_GET(self) -> None: self.end_headers() self.wfile.write(body) - def log_message(self, *args: object) -> None: # silence server logs + def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- silence server logs, stdlib signature pass @@ -53,5 +55,5 @@ def worker(tid: int) -> None: server.shutdown() # Every response must echo its own request's number; a mismatch is pool cross-talk. - mismatches = [(n, status, text) for n, status, text in results if status != 200 or text != str(n)] + mismatches = [(n, status, text) for n, status, text in results if status != _HTTP_OK or text != str(n)] assert mismatches == [] diff --git a/tests/test_retry_budget_freethreaded_stress.py b/tests/test_retry_budget_freethreaded_stress.py index 9ff9f93..6348657 100644 --- a/tests/test_retry_budget_freethreaded_stress.py +++ b/tests/test_retry_budget_freethreaded_stress.py @@ -15,6 +15,7 @@ from httpware import Client, Retry from httpware.middleware.resilience.budget import RetryBudget + _N_THREADS = 16 _N_OPS = 100 From d9e27bfca1d8ca6eb69c956e3f3c474eb142786e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 11:29:30 +0300 Subject: [PATCH 11/13] docs(audit): correct httpx2 boundary test counts to match committed test The boundary paragraph reported the initial exploratory scratch numbers (32 threads, 3x12,800) rather than the committed test (16 threads x 100 = 1,600, single run). Describe the committed regression test accurately and attribute the heavier design-time validation separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- planning/audits/2026-07-18-free-threading-audit.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/planning/audits/2026-07-18-free-threading-audit.md b/planning/audits/2026-07-18-free-threading-audit.md index 4f3c309..3b2126c 100644 --- a/planning/audits/2026-07-18-free-threading-audit.md +++ b/planning/audits/2026-07-18-free-threading-audit.md @@ -24,12 +24,14 @@ cp313t wheel. ## httpx2 shared-pool boundary `tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism` -(marked `stress`) drives 32 threads sharing one `httpx2` client + connection -pool, 3 × 12,800 requests, on 3.14t with the GIL disabled. Every response is -verified against its own request. Result: **zero cross-talk, zero crashes.** -httpx2 is a dependency httpware can't self-certify beyond this boundary test — -this is the recorded regression evidence that it holds under true thread -parallelism. +(marked `stress`) drives 16 threads sharing one `httpx2` client + connection +pool, 100 requests per thread (1,600 total), in a single run on 3.14t with the +GIL disabled. Every response is verified against its own request. Result: +**zero cross-talk, zero crashes.** (The initial exploratory validation during +design was heavier — 32 threads, 3 × 12,800 requests — and also clean; the +committed test is the trimmed, CI-fast regression guard.) httpx2 is a +dependency httpware can't self-certify beyond this boundary test — this is the +recorded regression evidence that it holds under true thread parallelism. ## Free-threaded stress-test suite From 399911894d7d51f6083635a9eba9cc9ee747059e Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 11:42:41 +0300 Subject: [PATCH 12/13] feat: certify free-threading (nogil) support Add the Free Threading :: 2 - Beta classifier and promote the nogil work into the living docs: architecture/resilience.md notes the Lock-based components' free-threaded stress verification and the honest ~1.9x contention-benchmark slowdown (correctness, not speed), architecture/testing.md documents the stress marker convention, and architecture/overview.md adds a supported-versions section. Records the 3.13t msgspec-wheel deferral and ships 0.16.0 release notes. --- architecture/overview.md | 4 ++ architecture/resilience.md | 2 + architecture/testing.md | 3 +- ...6-07-18.01-nogil-free-threading-support.md | 12 +++--- planning/deferred.md | 4 ++ planning/releases/0.16.0.md | 41 +++++++++++++++++++ pyproject.toml | 1 + 7 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 planning/releases/0.16.0.md diff --git a/architecture/overview.md b/architecture/overview.md index 91d8386..c73ebac 100644 --- a/architecture/overview.md +++ b/architecture/overview.md @@ -4,6 +4,10 @@ `httpx2` is part of the public surface. Exposing `httpx2.Request`/`httpx2.Response` is the design — `httpware` does not own a full abstraction over the underlying HTTP client. +## Supported versions + +`httpware` requires Python 3.11+ and is tested on the standard (GIL) builds of 3.11–3.14 in CI. It also carries Beta-tier free-threading support (`Free Threading :: 2 - Beta` classifier): a dedicated `pytest-freethreaded` CI job runs the full suite on free-threaded CPython `3.14t` with the GIL disabled. `3.13t` is deferred pending an msgspec cp313t wheel (see `planning/deferred.md`); see [Testing](testing.md) for the `stress` marker convention and [Resilience](resilience.md) for what free-threading correctness covers. + ## Architectural invariants These are non-negotiable, but **enforcement varies — do not assume CI will catch a violation.** Machine-checked: `print()` (ruff `T201`) and a blanket `# type: ignore` (ruff `PGH003`). Partially checked: the `httpx2._` ban — ruff `SLF001` flags private *attribute* access (`httpx2._foo`) but not a *used* private import (`from httpx2._internal import …`). Review-only: the future-import and global-logging bans, and `# type: ignore[]` vs `# ty: ignore[]`. The "why" exists so future contributors can judge edge cases instead of blindly following the rule. diff --git a/architecture/resilience.md b/architecture/resilience.md index ea89b33..82064d6 100644 --- a/architecture/resilience.md +++ b/architecture/resilience.md @@ -2,6 +2,8 @@ `httpware` ships a resilience suite under `httpware.middleware.resilience`, composed via the standard middleware chain (Seam A). It is pure stdlib — no optional extra. +The thread-shared components — the sync `Bulkhead` and `CircuitBreaker` (`threading.Semaphore`/`threading.Lock`) and the `RetryBudget` shared across a sync `Client`/`AsyncClient` pair — are stress-verified for correctness under free-threaded CPython (3.14t, GIL disabled) via real-thread `pytest.mark.stress` tests; the `httpx2` connection pool httpware's terminal relies on has its own boundary stress test (see [Testing](testing.md)). The async `AsyncBulkhead`/`AsyncCircuitBreaker` hold no thread-shared mutable state — they use asyncio primitives and the single-event-loop guard rejects cross-loop use (deterministically tested, see [Bulkhead](#bulkhead)) — so they are single-loop-bound and not thread-parallel-stressed by design. This is a correctness certification (`Free Threading :: 2 - Beta` classifier), not a performance claim — a contention benchmark on `RetryBudget` found free-threaded 3.14t ~1.9x *slower* than GIL 3.11 for a single-shared-lock hot loop (`planning/audits/2026-07-18-free-threading-audit.md`); lock contention dominates that access pattern. + ## Retry + RetryBudget `Retry` (and `AsyncRetry`) is a retry middleware backed by a Finagle-style `RetryBudget` — a token bucket that caps the proportion of traffic spent on retries so a degraded backend cannot be amplified into a retry storm. `RetryBudget` is a single thread-safe class shared by both worlds: all mutations go through a `threading.Lock`, so state is never torn. "Safe" here means no corruption, not non-blocking — when one budget is shared across a (sync `Client`, `AsyncClient`) pair, a sync thread holding the lock can briefly block the event-loop thread's acquisition. The critical section is intentionally tiny to bound that latency. Backoff between attempts uses full-jitter. diff --git a/architecture/testing.md b/architecture/testing.md index 3a512f7..90fc8e6 100644 --- a/architecture/testing.md +++ b/architecture/testing.md @@ -3,5 +3,6 @@ - **`pytest-asyncio` auto mode.** Async test functions do not require `@pytest.mark.asyncio`. The setting lives in `pyproject.toml` under `[tool.pytest.ini_options]`. - **`httpx2.MockTransport` for transport mocking, not `respx`.** Tests construct `httpx2.AsyncClient(transport=httpx2.MockTransport(handler))` and pass it as `httpx2_client=` to `AsyncClient` (or the sync equivalent `httpx2.Client(transport=httpx2.MockTransport(handler))` to `Client`). `MockTransport` is a first-party `httpx2` API; `respx` targets `httpx` (not `httpx2`) and patches its internals directly — see `docs/testing.md` for why. - **Hypothesis property-based tests** for concurrency-sensitive code: `RetryBudget`, `Bulkhead`, retry interleaving. Files are named `test_*_props.py` so they are easy to grep and treat separately in CI. -- **Performance tests are opt-in.** The `perf` pytest marker is registered in `pyproject.toml`; the default `addopts` line includes `-m 'not perf'`. Run benchmarks explicitly with `pytest -m perf`. +- **Benchmarks are standalone scripts, not pytest tests.** Performance characterization (e.g. `benchmarks/contention.py`) lives under `benchmarks/` (coverage-omitted via `run.omit`), run explicitly and never gated in CI — shared-runner perf is too noisy to gate on. +- **`stress` marker for free-threading proof.** Registered in `pyproject.toml`, `stress`-marked tests drive real thread parallelism (`ThreadPoolExecutor`, shared instances, high iteration counts, invariant-not-timing assertions) against `httpware`'s Lock/Semaphore-based components and the shared `httpx2` connection pool. They run in the default suite too, so they count toward coverage under the GIL — but the GIL serializes bytecode execution, so a GIL run only exercises the code path, it does not *prove* thread-safety. That proof comes from the `pytest-freethreaded` CI job, which runs the full suite on free-threaded CPython `3.14t` with the GIL disabled (the job asserts `sys._is_gil_enabled()` is False in a shell step before running the suite). `3.13t` is deferred — see `planning/deferred.md` — pending an msgspec cp313t wheel. - **Coverage is 100% line coverage.** New code is expected to maintain this. diff --git a/planning/changes/2026-07-18.01-nogil-free-threading-support.md b/planning/changes/2026-07-18.01-nogil-free-threading-support.md index 1ec891b..1112f46 100644 --- a/planning/changes/2026-07-18.01-nogil-free-threading-support.md +++ b/planning/changes/2026-07-18.01-nogil-free-threading-support.md @@ -1,5 +1,5 @@ --- -summary: Certify httpware under free-threaded CPython (PEP 703) — free-threaded CI on 3.14t, parallel stress tests, a contention benchmark, and the Free Threading classifier (added last). +summary: Certified httpware under free-threaded CPython (PEP 703) — a `pytest-freethreaded` CI job on 3.14t, four real-thread stress tests (RetryBudget, CircuitBreaker, Bulkhead, httpx2 shared-pool boundary) plus a deterministic event-loop-guard cross-loop test, a committed contention benchmark (nogil ~1.9x slower for the single-shared-lock workload — correctness, not speedup), and the `Free Threading :: 2 - Beta` classifier shipped last. 3.13t deferred on the msgspec cp313t wheel gap. --- # Design: Free-threading (nogil) support @@ -76,11 +76,11 @@ they exercise concurrency under the GIL too but only *prove* safety on 3.14t. ## Testing - Full suite green under `uv run --python 3.14t` locally and in the new CI job, - GIL disabled (a conftest guard asserts `sys._is_gil_enabled() is False` in - that job). -- `pytest -m stress` on 3.14t: RetryBudget / breaker / bulkhead / guard - invariants hold across many-thread runs; the httpx2 boundary test shows no - cross-talk. + GIL disabled (the `pytest-freethreaded` job asserts `sys._is_gil_enabled() is + False` in a shell step before running the suite). +- `pytest -m stress` on 3.14t: RetryBudget / breaker / bulkhead invariants hold + across many-thread runs, and the httpx2 boundary test shows no cross-talk; a + separate deterministic test covers the event-loop guard's cross-loop rejection. - `just lint-ci` and the planning validator unchanged and green. ## Risk diff --git a/planning/deferred.md b/planning/deferred.md index 4624f45..112b3b5 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -6,6 +6,10 @@ As of 0.7.0, all planned epics (3, 4, 5, 6) are closed — see the [change Index ## Open +### Free-threading + +- **3.13t free-threaded CI** — blocked on a msgspec cp313t wheel; add the interpreter to the `pytest-freethreaded` job when the wheel ships. + ### Resilience - **CircuitBreaker — manual control** (`src/httpware/middleware/resilience/circuit_breaker.py`) — the trip-mode work is done (0.13.0 shipped the opt-in time-based failure-rate mode) and 0.14.0 shipped the read-only `state` property + public `CircuitState` enum. The one remaining piece is `force_open`/`force_closed` (Polly's `ManualControl`) — the genuinely YAGNI half for an HTTP *client* (you'd usually just stop sending requests), keyed off the 0.10.0 audit's events-only control-surface decision (decision 4). Demand-gated. diff --git a/planning/releases/0.16.0.md b/planning/releases/0.16.0.md new file mode 100644 index 0000000..27172b3 --- /dev/null +++ b/planning/releases/0.16.0.md @@ -0,0 +1,41 @@ +# httpware 0.16.0 — free-threading (nogil) support, Beta + +Certifies `httpware` under free-threaded CPython (PEP 703), backed by CI +evidence rather than a bare classifier. + +## Feature + +- **Free-threading support (Beta).** Adds the + `Programming Language :: Python :: Free Threading :: 2 - Beta` classifier. + A new `pytest-freethreaded` CI job runs the full test suite (all extras) on + free-threaded CPython `3.14t` with the GIL disabled. Four real-thread stress + tests (marked `pytest.mark.stress`) exercise the Lock/Semaphore-based + resilience components — `RetryBudget`, `CircuitBreaker`/ + `AsyncCircuitBreaker`, `Bulkhead`/`AsyncBulkhead` — and the shared `httpx2` + connection pool, with zero cross-talk or crashes under parallelism; a fifth, + deterministic test covers the single-event-loop guard's cross-loop rejection. `3.13t` is deferred until msgspec ships a cp313t + wheel (`planning/deferred.md`). + +## Why + +httpware's resilience suite is built on `threading.Lock`/`Semaphore` and a +shared client across threads is a documented usage pattern; free-threaded +CPython removes the GIL that currently masks latent races. This release adds +the missing proof: a committed contention benchmark +(`benchmarks/contention.py`, `planning/audits/2026-07-18-free-threading-audit.md`) +found free-threaded 3.14t **~1.9x slower** than GIL 3.11 for `RetryBudget`'s +single-shared-lock hot loop — lock contention dominates that access pattern. +Free-threading support here is a correctness certification, not a +performance claim. + +## Downstream + +No API changes. Safe to upgrade unconditionally; the classifier and CI job +are the only visible additions. + +## Internals + +- New `stress` pytest marker (`architecture/testing.md`): runs under the GIL + too (counts toward coverage) but only *proves* thread-safety on 3.14t. +- `architecture/resilience.md`, `architecture/overview.md` updated with the + free-threading promotion. diff --git a/pyproject.toml b/pyproject.toml index d0b5b76..e27f3ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Free Threading :: 2 - Beta", "Typing :: Typed", "Topic :: Software Development :: Libraries", "Topic :: Internet :: WWW/HTTP", From 3e9f6e9515d782192463bc1758386f10ed89a386 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 12:31:30 +0300 Subject: [PATCH 13/13] test: consolidate free-threading stress tests onto existing coverage The RetryBudget and event-loop-guard free-threading tests duplicated existing coverage (test_retry_budget_threadsafety.py's two barrier-synced thread tests; test_bulkhead.py::test_cross_loop_acquire_raises_runtimeerror). Delete the two near-duplicate files and instead mark the existing RetryBudget thread-safety tests @pytest.mark.stress, so the free-threading proof set is labelled without redundant tests. Update the audit doc's test list and drop the now-imprecise "four/five" counts from the change summary + release notes (also scoping the release-notes stress claim to the sync + shared components, matching the resilience.md correction). Full suite 783 passing at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../audits/2026-07-18-free-threading-audit.md | 27 +++++----- ...6-07-18.01-nogil-free-threading-support.md | 2 +- planning/releases/0.16.0.md | 9 ++-- tests/test_event_loop_guard_freethreaded.py | 34 ------------ .../test_retry_budget_freethreaded_stress.py | 54 ------------------- tests/test_retry_budget_threadsafety.py | 4 ++ 6 files changed, 23 insertions(+), 107 deletions(-) delete mode 100644 tests/test_event_loop_guard_freethreaded.py delete mode 100644 tests/test_retry_budget_freethreaded_stress.py diff --git a/planning/audits/2026-07-18-free-threading-audit.md b/planning/audits/2026-07-18-free-threading-audit.md index 3b2126c..00e1ca7 100644 --- a/planning/audits/2026-07-18-free-threading-audit.md +++ b/planning/audits/2026-07-18-free-threading-audit.md @@ -35,20 +35,21 @@ recorded regression evidence that it holds under true thread parallelism. ## Free-threaded stress-test suite -Five tests (four marked `pytest.mark.stress`, one deterministic cross-loop -test) exercise real thread parallelism against httpware's Lock/Semaphore-based -components. All five pass on 3.14t with `sys._is_gil_enabled() is False` -(verified directly, and via the `pytest-freethreaded` CI job which asserts the -GIL is disabled before running the suite): +Real thread-parallelism tests exercise httpware's Lock/Semaphore-based +components; five carry `pytest.mark.stress` and a sixth deterministically +covers the cross-loop guard. All pass on 3.14t with `sys._is_gil_enabled() is +False` (verified directly, and via the `pytest-freethreaded` CI job which +asserts the GIL is disabled before running the suite): -- `tests/test_retry_budget_freethreaded_stress.py::test_shared_retry_budget_survives_thread_parallelism` -- `tests/test_circuit_breaker_freethreaded_stress.py::test_circuit_breaker_opens_consistently_under_parallel_failures` -- `tests/test_bulkhead_freethreaded_stress.py::test_bulkhead_never_exceeds_max_concurrent_under_parallelism` -- `tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism` -- `tests/test_event_loop_guard_freethreaded.py::test_async_bulkhead_rejects_second_event_loop` - (deterministically covers the guard's outer cross-loop raise; the inner - double-checked-lock arm stays `# pragma: no cover` — free-threading makes it - reachable but only nondeterministically, so it is not asserted here) +- `tests/test_retry_budget_threadsafety.py::test_concurrent_deposit_withdraw_does_not_corrupt` (`stress`) +- `tests/test_retry_budget_threadsafety.py::test_concurrent_only_deposit_count_matches` (`stress`) +- `tests/test_circuit_breaker_freethreaded_stress.py::test_circuit_breaker_opens_consistently_under_parallel_failures` (`stress`) +- `tests/test_bulkhead_freethreaded_stress.py::test_bulkhead_never_exceeds_max_concurrent_under_parallelism` (`stress`) +- `tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism` (`stress`) +- `tests/test_bulkhead.py::test_cross_loop_acquire_raises_runtimeerror` (deterministic; + covers the guard's outer cross-loop raise. The inner double-checked-lock arm + stays `# pragma: no cover` — free-threading makes it reachable but only + nondeterministically, so it is not asserted) Each stress test uses invariant assertions (final counts, exhaustion bounds, state consistency) rather than interleaving-dependent timing, per diff --git a/planning/changes/2026-07-18.01-nogil-free-threading-support.md b/planning/changes/2026-07-18.01-nogil-free-threading-support.md index 1112f46..1dbcfa2 100644 --- a/planning/changes/2026-07-18.01-nogil-free-threading-support.md +++ b/planning/changes/2026-07-18.01-nogil-free-threading-support.md @@ -1,5 +1,5 @@ --- -summary: Certified httpware under free-threaded CPython (PEP 703) — a `pytest-freethreaded` CI job on 3.14t, four real-thread stress tests (RetryBudget, CircuitBreaker, Bulkhead, httpx2 shared-pool boundary) plus a deterministic event-loop-guard cross-loop test, a committed contention benchmark (nogil ~1.9x slower for the single-shared-lock workload — correctness, not speedup), and the `Free Threading :: 2 - Beta` classifier shipped last. 3.13t deferred on the msgspec cp313t wheel gap. +summary: Certified httpware under free-threaded CPython (PEP 703) — a `pytest-freethreaded` CI job on 3.14t, real-thread stress tests for RetryBudget, CircuitBreaker, Bulkhead, and the httpx2 shared-pool boundary, plus a deterministic event-loop-guard cross-loop test, a committed contention benchmark (nogil ~1.9x slower for the single-shared-lock workload — correctness, not speedup), and the `Free Threading :: 2 - Beta` classifier shipped last. 3.13t deferred on the msgspec cp313t wheel gap. --- # Design: Free-threading (nogil) support diff --git a/planning/releases/0.16.0.md b/planning/releases/0.16.0.md index 27172b3..c58edfc 100644 --- a/planning/releases/0.16.0.md +++ b/planning/releases/0.16.0.md @@ -8,11 +8,10 @@ evidence rather than a bare classifier. - **Free-threading support (Beta).** Adds the `Programming Language :: Python :: Free Threading :: 2 - Beta` classifier. A new `pytest-freethreaded` CI job runs the full test suite (all extras) on - free-threaded CPython `3.14t` with the GIL disabled. Four real-thread stress - tests (marked `pytest.mark.stress`) exercise the Lock/Semaphore-based - resilience components — `RetryBudget`, `CircuitBreaker`/ - `AsyncCircuitBreaker`, `Bulkhead`/`AsyncBulkhead` — and the shared `httpx2` - connection pool, with zero cross-talk or crashes under parallelism; a fifth, + free-threaded CPython `3.14t` with the GIL disabled. Real-thread stress tests + (marked `pytest.mark.stress`) exercise the thread-shared components — the sync + `Bulkhead`, `CircuitBreaker`, and the shared `RetryBudget` — plus the `httpx2` + connection pool, with zero cross-talk or crashes under parallelism; a separate deterministic test covers the single-event-loop guard's cross-loop rejection. `3.13t` is deferred until msgspec ships a cp313t wheel (`planning/deferred.md`). diff --git a/tests/test_event_loop_guard_freethreaded.py b/tests/test_event_loop_guard_freethreaded.py deleted file mode 100644 index 6de09b0..0000000 --- a/tests/test_event_loop_guard_freethreaded.py +++ /dev/null @@ -1,34 +0,0 @@ -"""An async resilience middleware binds to its first event loop; a second loop is rejected. - -This deterministically exercises the guard's outer cross-loop raise (the reachable arm). The -inner double-checked-lock arm stays `# pragma: no cover`: it needs two threads to bind the loop -simultaneously, which free-threading can reach but only nondeterministically. -""" - -import asyncio -from http import HTTPStatus - -import httpx2 -import pytest - -from httpware import AsyncBulkhead, AsyncClient - - -def _ok(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.OK, request=request) - - -async def _drive(bulkhead: AsyncBulkhead) -> None: - client = AsyncClient( - httpx2_client=httpx2.AsyncClient(transport=httpx2.MockTransport(_ok)), - middleware=[bulkhead], - ) - async with client: - await client.get("https://example.test/x") - - -def test_async_bulkhead_rejects_second_event_loop() -> None: - bulkhead = AsyncBulkhead(max_concurrent=2) - asyncio.run(_drive(bulkhead)) # binds to loop #1 - with pytest.raises(RuntimeError, match="single event loop"): - asyncio.run(_drive(bulkhead)) # loop #2 -> rejected diff --git a/tests/test_retry_budget_freethreaded_stress.py b/tests/test_retry_budget_freethreaded_stress.py deleted file mode 100644 index 6348657..0000000 --- a/tests/test_retry_budget_freethreaded_stress.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Free-threaded stress: a shared RetryBudget stays consistent under real thread parallelism. - -Meaningful under free-threaded CPython (3.14t), where threads run Python in parallel; also a -valid concurrency check under the GIL. Verify-first: this is expected to PASS (RetryBudget is -already lock-guarded). A failure means a real race. -""" - -import contextlib -import threading -from http import HTTPStatus - -import httpx2 -import pytest - -from httpware import Client, Retry -from httpware.middleware.resilience.budget import RetryBudget - - -_N_THREADS = 16 -_N_OPS = 100 - - -def _fail(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(HTTPStatus.SERVICE_UNAVAILABLE, request=request) - - -def _fixed_clock() -> float: - return 0.0 - - -@pytest.mark.stress -def test_shared_retry_budget_survives_thread_parallelism() -> None: - # Pinned clock: all deposits share timestamp 0.0, so _purge (strict `< cutoff`) evicts - # nothing and the deposit count is exact — any lost/torn append would change it. - budget = RetryBudget(ttl=60.0, min_retries_per_sec=1000.0, percent_can_retry=0.5, _now=_fixed_clock) - client = Client( - httpx2_client=httpx2.Client(transport=httpx2.MockTransport(_fail)), - middleware=[Retry(budget=budget, max_attempts=2, base_delay=0.0001, max_delay=0.001)], - ) - - def worker() -> None: - for _ in range(_N_OPS): - with contextlib.suppress(Exception): - client.get("https://example.test/x") - - threads = [threading.Thread(target=worker) for _ in range(_N_THREADS)] - for t in threads: - t.start() - for t in threads: - t.join() - client.close() - - # One deposit per request (deposit-hoist counts requests, not attempts). - assert len(budget._deposits) == _N_THREADS * _N_OPS # noqa: SLF001 diff --git a/tests/test_retry_budget_threadsafety.py b/tests/test_retry_budget_threadsafety.py index 1ebc489..44fa57b 100644 --- a/tests/test_retry_budget_threadsafety.py +++ b/tests/test_retry_budget_threadsafety.py @@ -7,6 +7,8 @@ import threading +import pytest + from httpware.middleware.resilience.budget import RetryBudget @@ -14,6 +16,7 @@ _N_OPS_PER_THREAD = 1000 +@pytest.mark.stress def test_concurrent_deposit_withdraw_does_not_corrupt() -> None: budget = RetryBudget(ttl=60.0, min_retries_per_sec=1000.0, percent_can_retry=0.5) errors: list[BaseException] = [] @@ -42,6 +45,7 @@ def worker() -> None: assert len(budget._deposits) > 0 # noqa: SLF001 +@pytest.mark.stress def test_concurrent_only_deposit_count_matches() -> None: budget = RetryBudget(ttl=60.0) barrier = threading.Barrier(_N_THREADS)