Skip to content

ci: test on free-threaded Python and stress a shared RESTClient - #1

Closed
chipkent wants to merge 4 commits into
masterfrom
ci/free-threaded
Closed

chipkent wants to merge 4 commits into
masterfrom
ci/free-threaded

Conversation

@chipkent

@chipkent chipkent commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Preview only. This is a draft in the fork showing the exact text of the PR planned for massive-com/client-python. It will be closed, not merged, once the upstream PR is opened.

Fixes #<issue: F7>

Summary

People use this client from multi-threaded programs. Free-threaded Python (3.13t and 3.14t) removes the GIL, and the GIL can hide race conditions. This PR runs the unit tests on free-threaded Python, adds a test that shares one RESTClient between many threads, and stops pook mocks leaking between REST test classes.

The package itself does not change. Only tests, CI and the dev-dependency layout do.

Changes

CI. A new test-free-threaded job runs make test on 3.13t and 3.14t with PYTHON_GIL=0. It first checks that the GIL is really off, and fails if it is not. The regular matrix now also covers 3.12, 3.13 and 3.14.

The job installs Poetry with pipx, the installer Poetry recommends, which keeps Poetry on the runner's normal Python whatever Python is under test.

Dependency groups. The deprecated [tool.poetry.dev-dependencies] table is split into two Poetry dependency groups:

  • test: pook, the only extra package the unit tests need;
  • dev: black, mypy, the types-* stubs, Sphinx and its plugins, and orjson.

The free-threaded job installs only main and test, because orjson (3.13.0 in the lock) has no free-threaded wheel. A plain poetry install still installs everything, so local development, linting, docs and releases work as before, and Poetry stops printing its deprecation warning. No versions change; re-locking merged a stale duplicate entry for typing-extensions, a dev-only dependency, into 4.16.0.

Shared-client stress test (test_rest/test_concurrency.py). 16 threads share one RESTClient and make 200 calls each against a local HTTP server that echoes each request back. The test checks that the threads really ran at the same time (the server saw more than one request in flight), that every response matches the request that produced it (path, exact query string, Authorization header and per-request edge headers), that per-request headers never show up on another call (odd calls send edge headers, even calls send none), and that the client's default headers are unchanged afterwards.

The calls that send headers use RESTClient._get, which every endpoint method calls. Today, passing options to an endpoint method also adds it to the query string (fixed in a separate PR), and that would get in the way of checking the exact query.

Pook mocks no longer leak between test classes (test_rest/base.py). BaseTest turned pook on in setUpClass but never turned it off, so pook kept intercepting HTTP for every later test. The stress test above therefore failed in the full suite: pook rejected its requests to the local server.

The leak also hid a problem in two tests, test_list_stocks_dividends_ticker_any_of and test_get_snapshot_indices. Each makes the same request twice, and each mock answers once. Their second request was answered by a copy left over from an earlier class, so both failed when run on their own.

BaseTest now turns pook off in tearDownClass. A test class can list mocks that must answer more than once (persistent_mocks), and those are registered with pook's persist. All other mocks still answer once, because the pagination tests depend on it: a first-page mock would also match the request for the next page.

Test plan

  • make test passes on 3.10 to 3.14 (Ubuntu and Windows), and on 3.13t and 3.14t with PYTHON_GIL=0.
  • make lint passes on 3.10 and 3.14.
  • test_dividends and test_snapshots pass when run on their own.
  • The stress test fails if per-request headers are written into the client's default headers (checked by temporarily breaking _concat_headers).

Suggested order: land this before the WebSocket PRs, which rely on the free-threaded CI job.

@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@chipkent chipkent changed the title [CI only] ci/free-threaded ci: test on free-threaded Python and add concurrency tests Oct 24, 2026
Comment thread .github/workflows/test.yml Outdated
Comment on lines +52 to +65
- name: Install pypi deps
# orjson, a dev dependency used only by the examples, does not support
# free-threaded Python, so install the package and the test dependency
# (pook) at their locked versions instead of `poetry install`
run: |
poetry env use python${{ matrix.python-version }}
poetry install --only main
poetry run python - > "$RUNNER_TEMP/constraints.txt" <<'EOF'
import tomllib
with open("poetry.lock", "rb") as f:
for p in tomllib.load(f)["package"]:
print(f"{p['name']}=={p['version']}")
EOF
poetry run pip install pook -c "$RUNNER_TEMP/constraints.txt"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a major hack job. Why not do a proper fix? Is a proper fix difficult? This does not look like best practice.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it was a workaround. Replaced it with the proper fix in 2c4cf63 and af96494.

  • Root cause: orjson, a dev dependency used only by the examples, publishes no free-threaded wheels, and it sat in the same deprecated dev-dependencies table as the test dependencies. The job could only avoid it by hand-building a constraints file and calling pip.
  • 2c4cf63 (build): moves [tool.poetry.dev-dependencies] into Poetry dependency groups, test (pook) and dev (black, mypy, stubs, Sphinx, orjson), with versions unchanged. A plain poetry install still installs both, and Poetry's deprecation warning is gone. Re-locking also merged a stale split for the dev-only typing-extensions (4.12.2 → 4.16.0 below 3.15); nothing else changed.
  • af96494 (ci): the free-threaded job now just runs poetry install --only main,test. The constraints script and pip install are deleted. pipx install poetry stays, because it is Poetry's recommended installation method and keeps Poetry on the runner's regular Python; the comment now says so.

All 16 checks pass on af96494. The PR description is updated, and F1 and F2 are rebased onto the new branch.

@chipkent chipkent left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great PR direction overall — strong intent, good isolation of runtime code, and meaningful concurrency coverage.
My adversarial take: I don’t see a fundamental design flaw, but there are a few high-value edge cases and determinism risks worth tightening before upstreaming.

Adversarial review

1) Design correctness

  • ✅ Scope discipline is good: CI + tests + tooling only, no runtime behavior changes.
  • ✅ Free-threaded CI strategy is sound: explicitly running with PYTHON_GIL=0 and asserting GIL-off state is exactly what you want to prevent “false confidence” runs.
  • ✅ Dependency split (main,test vs dev) is good architecture for CI portability and avoiding unnecessary deps (orjson issue on free-threaded builds is a practical concern).

Adversarial concern: ensure this project’s contributor docs and Makefile assumptions still match the new Poetry grouping semantics. If make lint / docs targets implicitly relied on old dev-deps behavior, this can fail in fresh environments even if CI passes.


2) Code/test correctness risks to double-check

Given the PR shape (fake websocket server + threaded REST tests), the highest-risk failure modes are:

  1. Flaky synchronization in multi-thread tests

    • Look for sleeps/timeouts used as synchronization substitutes.
    • Prefer explicit barriers/events/condition variables with bounded waits and clear failure messages.
  2. Server teardown and port reuse races

    • Fake server must always close sockets and join threads in finally paths.
    • Ensure tests cannot leak background threads between cases.
  3. “Passes locally, flaky in CI” timing assumptions

    • 16 clients × reconnect/auth logic can produce scheduler-dependent ordering.
    • Assertions should be order-insensitive unless protocol ordering is guaranteed.
  4. Thread safety of captured request/message logs

    • If fake server stores per-connection messages in shared dict/list, ensure writes are lock-protected (or per-thread isolated + merge).
  5. REST header isolation checks

    • Great test idea. Verify the test actually mutates per-request headers concurrently and checks both:
      • response matches request-specific value
      • client-level default headers remain unchanged afterward

3) Conventions / project fit

  • ✅ Naming and test layout look consistent with a Python client repo (test_rest, test_websocket, fake server helper).
  • ✅ CI expansion to 3.12–3.14 + 3.13t/3.14t is conventionally good.
  • ⚠️ Ensure new tests are deterministic under Windows too (especially socket close semantics / timing), since your test plan includes Windows.

4) Merge readiness (adversarial verdict)

Status: Likely mergeable after a quick hardening pass.
Risk: Medium (not because of product/runtime changes, but because concurrency tests can silently be flaky and erode trust in CI).

Blocking issues found from provided context: none obvious.
Potential blockers to proactively eliminate before upstream PR:

  • nondeterministic waits/sleeps in concurrency tests
  • missing cleanup/join paths in fake server lifecycle
  • shared mutable state in test infrastructure without locks

5) Suggested concrete improvements before opening upstream

  • In test_websocket/fake_server.py, ensure all shared collections tracking messages/connections are protected by a lock (or replaced with thread-safe queues) so test infrastructure itself doesn’t introduce races under PYTHON_GIL=0.
  • In test_websocket/test_fake_server.py, replace timing-based waits (sleep-style sequencing) with explicit synchronization primitives (Event/Barrier) and bounded wait() assertions to reduce CI flakiness across Linux/Windows schedulers.
  • In test_rest/test_concurrency.py, add a strict post-run assertion that RESTClient default headers are byte-for-byte unchanged from pre-run state, so leakage detection is explicit and future regressions are caught immediately.
  • In free-threaded CI job setup, make failure logs include interpreter build flags / version details when GIL-off assertion fails, to speed root-cause analysis on runner image drift.

@chipkent

Copy link
Copy Markdown
Owner Author

Addressed in a165599 (5 commits, no AI trailers). All 16 checks pass.

  • Retry test: test_rejected_handshake_is_retried now observes a real retry. FakeServer.rejected_handshakes counts 503 rejections. The test waits for at least 2, then lets the handshake through and asserts exactly one connection that holds T.AAPL.
  • Threading model: the fake server's state lives on its event loop, as the docstrings now say. The accessors return copies and raise RuntimeError when called from another thread, and a test covers that. The 16-client test's shared dict is lock-guarded.
  • Waits: wait_for(predicate, timeout, description) waits on an asyncio.Condition that is notified on every state change, with no polling and no sleeps. A timeout's error message names the server state.
  • REST header isolation: the default headers are compared byte for byte, order included, against a deepcopy taken beforehand. Every request goes through the public get_ticker_details(..., raw=True). EchoServer subclasses ThreadingHTTPServer instead of monkeypatching, and the type: ignores are gone.
  • pook leak, fixed at its source: BaseTest now calls pook.off() in tearDownClass. This exposed two upstream tests that passed only because of the leak (they also fail alone on master), and a separate commit fixes them. Every REST module now passes when run alone.
  • Free-threaded job: prints and asserts sys.version, Py_GIL_DISABLED, and sys._is_gil_enabled().
  • Makefile and docs: both still match the Poetry groups. A plain poetry install still installs every group.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Closing or dropping a fake connection from a message hook deadlocks its handler and can hang server cleanup.

1 open issue
What changed in this PR

This PR adds free-threaded Python CI coverage and concurrency test infrastructure for the Massive Python client, without changing package runtime code.

Changes:

  • Expands Python CI coverage and separates test dependencies from development tools.
  • Adds a scriptable WebSocket server and tests for protocol behavior, reconnection, and parallel clients.
  • Adds shared REST client concurrency coverage and isolates HTTP mocks between test classes.
File Description
test_websocket/​test_fake_server.py Tests fake-server behavior and parallel WebSocket clients.
test_websocket/​fake_server.py Adds a scriptable, per-instance WebSocket test server.
test_rest/​test_snapshots.py Re-registers mocks before a repeated request.
test_rest/​test_dividends.py Re-registers mocks before a repeated request.
test_rest/​test_concurrency.py Tests shared-client requests and header isolation across threads.
test_rest/​base.py Adds mock cleanup and reusable registration.
pyproject.toml Splits test and development dependency groups.
poetry.lock Updates dependency groups and consolidates typing-extensions.
.github/​workflows/​test.yml Expands regular Python coverage and adds free-threaded test jobs.

Review effort: Balanced

Comment thread test_websocket/fake_server.py Outdated
async def close(self, code: int = 1000, reason: str = ""):
"""Close with a close frame; 1000 is a clean close, e.g. 1011 is not."""
await self.websocket.close(code, reason)
await self.closed.wait()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR no longer contains the fake WebSocket server. It is now scoped to free-threaded CI and the shared-RESTClient stress test (5fbf82d). The fake server moves to the WebSocket subscription PR, the first one that uses it, and the close/drop-from-hook deadlock is fixed there with a test.

BaseTest turned pook on and registered every mock in setUpClass but never
turned it off. Mocks piled up across classes, and pook kept intercepting
urllib3 after the REST mock tests had run.

That breaks any later test that makes real HTTP requests: pook rejects them
with PookNoMatches because it has no mock for them. The next commit adds
such a test, which talks to a local HTTP server, and it fails in the full
suite without this fix.

The leak also hid a problem. Two tests repeat a request, and each mock
answers only once, so their second request was served by a copy that an
earlier class had registered. Both fail when run on their own.

Turn pook off in tearDownClass. Let a test class name the mocks that must
answer more than once; those are registered with persist. Other mocks still
answer once, because the pagination tests depend on it: a persistent
first-page mock would also match the request for the next page.
16 threads share one RESTClient and make 200 calls each against a local
HTTP server that echoes every request back. The threads start together
behind a barrier, and the server records how many requests it had in
flight at once, so the test checks that they really overlapped.

Every response is compared with the request that produced it: the path,
the exact query string, the Authorization header, and the edge headers.
Odd calls send per-request edge headers and even calls send none, so a
header leaking from one thread into another call shows up as a mismatch.
After the run, the client's default headers must be unchanged: same keys,
same values, same order.

The calls with headers go through RESTClient._get, which every endpoint
method uses. Passing `options` to an endpoint method also adds it to the
query string, and that would get in the way of checking the exact query.
Replace the deprecated [tool.poetry.dev-dependencies] section with a
`test` group (pook, all the unit tests need) and a `dev` group (linters,
type stubs, Sphinx, orjson for the examples). `poetry install` still
installs both, so local and CI workflows are unchanged, while
`poetry install --only main,test` installs just what `make test` needs.

Versions are unchanged. Re-locking also merged a stale duplicate entry
for typing-extensions (4.12.2 below Python 3.15, 4.16.0 from 3.15) into
4.16.0, a transitive dependency of the dev tools.
Add a job that runs `make test` on 3.13t and 3.14t with PYTHON_GIL=0, and
extend the regular matrix to 3.12, 3.13 and 3.14. The free-threaded job
installs only the main and test dependency groups: the dev group is not
needed to run the tests, and orjson in it does not support free-threaded
Python.
@chipkent chipkent changed the title ci: test on free-threaded Python and add concurrency tests ci: test on free-threaded Python and stress a shared RESTClient Oct 24, 2026
@chipkent

Copy link
Copy Markdown
Owner Author

Superseded by smaller PRs split by content: #9, #10, #11. Closing this preview; the replacements carry the same changes, opt-in where behaviour changes.

@chipkent chipkent closed this Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants