Skip to content

Commit 54848e2

Browse files
authored
feat: free-threading (nogil) support (#108)
Certify httpware under free-threaded CPython (3.14t): a pytest-freethreaded CI job, real-thread stress tests for the shared resilience components (Bulkhead, CircuitBreaker, RetryBudget) + the httpx2 pool, a contention benchmark (nogil ~1.9x slower for the single-lock workload — correctness, not speedup), and the Free Threading :: 2 - Beta classifier. 3.13t deferred on the msgspec cp313t wheel gap. See planning/changes/2026-07-18.01-nogil-free-threading-support.md.
1 parent b2a4cad commit 54848e2

16 files changed

Lines changed: 491 additions & 4 deletions

.github/workflows/_checks.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,18 @@ jobs:
4545
- run: uv python pin ${{ matrix.python-version }}
4646
- run: just install
4747
- run: just test --cov-report xml
48+
49+
pytest-freethreaded:
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: actions/checkout@v6
53+
- uses: extractions/setup-just@v4
54+
- uses: astral-sh/setup-uv@v8.2.0
55+
with:
56+
enable-cache: true
57+
cache-dependency-glob: "**/pyproject.toml"
58+
- run: uv python install 3.14t
59+
- run: uv python pin 3.14t
60+
- run: just install
61+
- run: uv run --no-sync python -c "import sys; assert not sys._is_gil_enabled(), 'GIL is enabled'"
62+
- run: just test --cov-report xml

architecture/overview.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
`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.
66

7+
## Supported versions
8+
9+
`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.
10+
711
## Architectural invariants
812

913
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[<code>]` vs `# ty: ignore[<code>]`. The "why" exists so future contributors can judge edge cases instead of blindly following the rule.

architecture/resilience.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
`httpware` ships a resilience suite under `httpware.middleware.resilience`, composed via the standard middleware chain (Seam A). It is pure stdlib — no optional extra.
44

5+
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.
6+
57
## Retry + RetryBudget
68

79
`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.

architecture/testing.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
- **`pytest-asyncio` auto mode.** Async test functions do not require `@pytest.mark.asyncio`. The setting lives in `pyproject.toml` under `[tool.pytest.ini_options]`.
44
- **`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.
55
- **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.
6-
- **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`.
6+
- **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.
7+
- **`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.
78
- **Coverage is 100% line coverage.** New code is expected to maintain this.

benchmarks/__init__.py

Whitespace-only changes.

benchmarks/contention.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Contention benchmark: shared Lock-based resilience components, GIL vs free-threaded.
2+
3+
Answers whether removing the GIL helps httpware's shared components or whether lock contention
4+
eats the gain. Run under both interpreters and compare:
5+
6+
uv run --python 3.11 benchmarks/contention.py
7+
uv run --python 3.14t benchmarks/contention.py
8+
9+
Not a test and not a CI gate (shared-runner perf is too noisy); a characterization tool with a
10+
recorded baseline in planning/audits/2026-07-18-free-threading-audit.md.
11+
"""
12+
13+
import sys
14+
import threading
15+
import time
16+
17+
from httpware.middleware.resilience.budget import RetryBudget
18+
19+
20+
_N_THREADS = 8
21+
_N_OPS = 200_000
22+
23+
24+
def _run() -> float:
25+
budget = RetryBudget(ttl=60.0, min_retries_per_sec=1_000_000.0, percent_can_retry=1.0)
26+
27+
def worker() -> None:
28+
for _ in range(_N_OPS):
29+
budget.deposit()
30+
budget.try_withdraw()
31+
32+
threads = [threading.Thread(target=worker) for _ in range(_N_THREADS)]
33+
start = time.perf_counter()
34+
for t in threads:
35+
t.start()
36+
for t in threads:
37+
t.join()
38+
return time.perf_counter() - start
39+
40+
41+
def main() -> None:
42+
gil = getattr(sys, "_is_gil_enabled", lambda: True)()
43+
elapsed = _run()
44+
ops = _N_THREADS * _N_OPS * 2
45+
message = (
46+
f"python={sys.version.split()[0]} gil_enabled={gil} threads={_N_THREADS} "
47+
f"ops={ops} elapsed={elapsed:.3f}s throughput={ops / elapsed:,.0f} ops/s"
48+
)
49+
# T201 is fine here: benchmarks/ is not library code and is coverage-omitted.
50+
print(message) # noqa: T201
51+
52+
53+
if __name__ == "__main__":
54+
main()
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# httpware free-threading (nogil) audit — 2026-07-18
2+
3+
**Status:** complete
4+
**Scope:** empirical findings backing `planning/changes/2026-07-18.01-nogil-free-threading-support.md`
5+
— the extras wheel matrix, the httpx2 shared-pool boundary result, the
6+
free-threaded stress-test suite, and the contention benchmark baseline
7+
(`benchmarks/contention.py`).
8+
9+
## Extras wheel matrix (free-threaded interpreters)
10+
11+
| Extra | 3.13t | 3.14t |
12+
|---|---|---|
13+
| pydantic |||
14+
| msgspec | ✗ (no cp313t wheel, versions 0.18–0.21.1) ||
15+
| otel (opentelemetry) | ✓ (pure-Python, no wheel gap) ||
16+
17+
3.14t is the only free-threaded interpreter with complete extras coverage
18+
from prebuilt wheels; all three extras keep the GIL disabled after import
19+
(verified — none silently re-enables it). This is why free-threaded CI
20+
(`.github/workflows/_checks.yml`, `pytest-freethreaded` job) targets `3.14t`
21+
only; 3.13t is deferred in `planning/deferred.md` until msgspec ships a
22+
cp313t wheel.
23+
24+
## httpx2 shared-pool boundary
25+
26+
`tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism`
27+
(marked `stress`) drives 16 threads sharing one `httpx2` client + connection
28+
pool, 100 requests per thread (1,600 total), in a single run on 3.14t with the
29+
GIL disabled. Every response is verified against its own request. Result:
30+
**zero cross-talk, zero crashes.** (The initial exploratory validation during
31+
design was heavier — 32 threads, 3 × 12,800 requests — and also clean; the
32+
committed test is the trimmed, CI-fast regression guard.) httpx2 is a
33+
dependency httpware can't self-certify beyond this boundary test — this is the
34+
recorded regression evidence that it holds under true thread parallelism.
35+
36+
## Free-threaded stress-test suite
37+
38+
Real thread-parallelism tests exercise httpware's Lock/Semaphore-based
39+
components; five carry `pytest.mark.stress` and a sixth deterministically
40+
covers the cross-loop guard. All pass on 3.14t with `sys._is_gil_enabled() is
41+
False` (verified directly, and via the `pytest-freethreaded` CI job which
42+
asserts the GIL is disabled before running the suite):
43+
44+
- `tests/test_retry_budget_threadsafety.py::test_concurrent_deposit_withdraw_does_not_corrupt` (`stress`)
45+
- `tests/test_retry_budget_threadsafety.py::test_concurrent_only_deposit_count_matches` (`stress`)
46+
- `tests/test_circuit_breaker_freethreaded_stress.py::test_circuit_breaker_opens_consistently_under_parallel_failures` (`stress`)
47+
- `tests/test_bulkhead_freethreaded_stress.py::test_bulkhead_never_exceeds_max_concurrent_under_parallelism` (`stress`)
48+
- `tests/test_httpx2_freethreaded_boundary.py::test_httpx2_shared_pool_no_crosstalk_under_parallelism` (`stress`)
49+
- `tests/test_bulkhead.py::test_cross_loop_acquire_raises_runtimeerror` (deterministic;
50+
covers the guard's outer cross-loop raise. The inner double-checked-lock arm
51+
stays `# pragma: no cover` — free-threading makes it reachable but only
52+
nondeterministically, so it is not asserted)
53+
54+
Each stress test uses invariant assertions (final counts, exhaustion bounds,
55+
state consistency) rather than interleaving-dependent timing, per
56+
`architecture/testing.md`'s stress-test convention.
57+
58+
## Contention benchmark: GIL vs free-threaded
59+
60+
`benchmarks/contention.py` (committed, not a CI gate — shared-runner perf is
61+
too noisy to gate on) drives 8 threads × 200,000 iterations of
62+
`RetryBudget.deposit()` + `RetryBudget.try_withdraw()` against one shared
63+
`RetryBudget` (3,200,000 total lock-guarded ops), measuring wall-clock
64+
throughput. Run twice per interpreter for stability:
65+
66+
```
67+
uv run --no-sync python benchmarks/contention.py
68+
python=3.11.9 gil_enabled=True threads=8 ops=3200000 elapsed=0.869s throughput=3,684,320 ops/s
69+
python=3.11.9 gil_enabled=True threads=8 ops=3200000 elapsed=0.869s throughput=3,683,289 ops/s
70+
71+
<ft-run venv>/bin/python benchmarks/contention.py
72+
python=3.14.6 gil_enabled=False threads=8 ops=3200000 elapsed=1.626s throughput=1,967,685 ops/s
73+
python=3.14.6 gil_enabled=False threads=8 ops=3200000 elapsed=1.634s throughput=1,958,734 ops/s
74+
```
75+
76+
**Interpretation:** for this workload — a tiny critical section
77+
(`deque` purge + append/compare under `threading.Lock`) hammered by 8 threads
78+
with no non-lock work between acquisitions — free-threaded 3.14t is
79+
**~1.9x slower** than GIL 3.11 (≈1.96M ops/s vs ≈3.68M ops/s), so **lock
80+
contention dominates and nogil does not help here**: free threads spend more
81+
wall-clock time contending for the same `threading.Lock` than the GIL's
82+
cooperative bytecode-level serialization costs, and `RetryBudget`'s
83+
correctness (proven by the stress test above) does not translate into a
84+
throughput win under this access pattern. This is expected for a
85+
single-shared-lock hot loop and is not a regression to fix — a real client
86+
workload interleaves this critical section with I/O and non-lock CPU work
87+
where free-threading's benefit (true parallel non-lock work) would show up
88+
where this microbenchmark cannot show it. `RetryBudget`'s thread-safety
89+
contract (`architecture/resilience.md`) is unaffected either way; this
90+
benchmark characterizes throughput, not correctness.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
---
2+
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.
3+
---
4+
5+
# Design: Free-threading (nogil) support
6+
7+
## Summary
8+
9+
Certify that httpware runs correctly under free-threaded CPython (PEP 703),
10+
backed by evidence rather than a bare classifier. Adds a free-threaded CI job
11+
(3.14t, full suite + all extras), parallel stress tests for the Lock-based
12+
resilience components and the shared httpx2 connection pool, a committed
13+
contention benchmark with a recorded baseline, and — last, once everything is
14+
green — the `Programming Language :: Python :: Free Threading` classifier.
15+
Verify-first: the public claim trails the proof.
16+
17+
## Motivation
18+
19+
httpware is a concurrency library: its resilience suite (`RetryBudget`,
20+
`CircuitBreaker`, `Bulkhead`) is built on `threading.Lock`/`Semaphore` and
21+
documents thread-safety invariants, and a shared client across threads is a
22+
documented usage pattern. Free-threaded CPython (free-threaded builds available
23+
since 3.13, officially supported in 3.14) removes the GIL that currently masks
24+
any latent races. The library already ships 3.13/3.14 classifiers and tests
25+
those *GIL* builds, but nothing exercises true thread parallelism.
26+
27+
Two empirical findings (2026-07-18) de-risk the work:
28+
29+
- **httpx2 2.5.0 holds under free-threading.** 32 threads sharing one client +
30+
connection pool, 3 × 12,800 requests on 3.14t with the GIL disabled, each
31+
response verified against its request: zero cross-talk, zero crashes.
32+
- **Extras wheel matrix on free-threaded interpreters.** pydantic ✓ (3.13t +
33+
3.14t), msgspec ✓ 3.14t but ✗ no cp313t wheel (0.18–0.21.1), otel pure-Python
34+
✓. **3.14t is the only interpreter with complete extras coverage from
35+
prebuilt wheels;** all three keep the GIL disabled after import.
36+
37+
## Design
38+
39+
Five workstreams, landed verify-first (classifier last):
40+
41+
1. **CI enablement.** Add one free-threaded job on `3.14t` to
42+
`.github/workflows/_checks.yml`, running the full suite + all extras (uv
43+
installs `3.14t`; extras resolve from prebuilt free-threaded wheels). The
44+
existing GIL matrix (3.11–3.14) is unchanged. 3.13t-free-threaded is deferred
45+
until msgspec ships a cp313t wheel (record in `deferred.md`).
46+
2. **Free-threaded audit + stress tests.** Audit httpware's own lock usage under
47+
parallelism, then add stress tests (real threads via `ThreadPoolExecutor`,
48+
shared instances, high iteration counts, invariant assertions) for
49+
`RetryBudget` token accounting, `CircuitBreaker` transitions, `Bulkhead`
50+
semaphore counting, and the event-loop guard's cross-loop rejection. TDD per
51+
component. **Specific target:** `_event_loop_guard.py`'s `# pragma: no cover`
52+
double-checked-locking arm is annotated "single-threaded tests can't
53+
trigger" — free-threading makes it reachable, so the pragma is re-examined
54+
(covered → removed, or reworded).
55+
3. **httpx2 boundary test.** Promote the shared-pool cross-talk stress harness
56+
into the suite (marked) as living regression evidence for the one dependency
57+
httpware can't self-certify.
58+
4. **Benchmarks.** A committed `benchmarks/` script measuring contended
59+
throughput (GIL vs free-threaded) on the shared Lock components — does nogil
60+
help, or does lock contention eat the gain? Baseline numbers recorded under
61+
`planning/audits/`.
62+
5. **Certify + promote.** Add the `Free Threading` classifier; promote into
63+
`architecture/resilience.md` (thread-safety prose), `architecture/testing.md`
64+
(stress-test convention + marker), `architecture/overview.md`; release notes.
65+
66+
Stress tests carry a `stress` pytest marker so the free-threaded job runs them;
67+
they exercise concurrency under the GIL too but only *prove* safety on 3.14t.
68+
69+
## Non-goals
70+
71+
- **3.13t support** — blocked on a msgspec cp313t wheel; deferred, not designed out.
72+
- **Certifying httpx2 itself** — out of our control; we ship evidence, optionally upstream later.
73+
- **CI perf regression gates** — benchmarks are characterization (committed script + recorded baseline), not a gate; shared-runner perf is too noisy.
74+
- **Reworking the Lock-based design** — the audit validates it; it does not rewrite it.
75+
76+
## Testing
77+
78+
- Full suite green under `uv run --python 3.14t` locally and in the new CI job,
79+
GIL disabled (the `pytest-freethreaded` job asserts `sys._is_gil_enabled() is
80+
False` in a shell step before running the suite).
81+
- `pytest -m stress` on 3.14t: RetryBudget / breaker / bulkhead invariants hold
82+
across many-thread runs, and the httpx2 boundary test shows no cross-talk; a
83+
separate deterministic test covers the event-loop guard's cross-loop rejection.
84+
- `just lint-ci` and the planning validator unchanged and green.
85+
86+
## Risk
87+
88+
- **msgspec 3.13t wheel gap (med likelihood / low impact).** Scopes free-threaded
89+
CI to 3.14t only. Mitigation: documented deferral; revisit on wheel availability.
90+
- **Flaky stress tests (med / med).** Probabilistic race detection. Mitigation:
91+
invariant assertions (not interleaving-dependent), tuned iteration counts, the
92+
`stress` marker isolates them from the default run.
93+
- **Audit surfaces a real race (low / high).** The 2026-06-14 deep audit already
94+
reasoned the loop-guard fast path is benign; a real race would be a genuine bug
95+
caught *before* shipping the claim — which is the point of verify-first.

planning/deferred.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ As of 0.7.0, all planned epics (3, 4, 5, 6) are closed — see the [change Index
66

77
## Open
88

9+
### Free-threading
10+
11+
- **3.13t free-threaded CI** — blocked on a msgspec cp313t wheel; add the interpreter to the `pytest-freethreaded` job when the wheel ships.
12+
913
### Resilience
1014

1115
- **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.

0 commit comments

Comments
 (0)