You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
import clientwright takes ~190 ms on Linux; ~80 ms of it is asyncio, pulled in eagerly by core.policy.concurrency #62
A bare clientwright install is meant to be usable for configuration alone, for example a CI job that builds ClientConfig and reads capabilities_matrix() without any transport installed. That import is relatively heavy, and most of the cost is asyncio, which configuration does not need.
Measurement
python:3.13-slim container, bare clientwright==0.5.0 (plus deadline-budget and servicewright for comparison), bytecode precompiled, five fresh processes, time of importlib.import_module(name) only:
clientwright.core.policy.concurrency imports asyncio at module level (core/policy/concurrency.py:15), and it sits on the import path of capabilities and plan, so every import clientwright pays for asyncio, ssl, socket and subprocess.
Proposal
Import asyncio lazily in core.policy.concurrency (or move its asyncio-dependent parts off the import path of capabilities and plan).
If a light configuration-only import is a goal, guard it in CI: after import clientwright, assert that asyncio is not in sys.modules, and record the import time per release.
Reproduced on master (0.5.0, Python 3.13, macOS, warm bytecode cache): import clientwright is ~27 ms median there, and asyncio is ~9 ms of it, the same third as in your container numbers. core.policy.concurrency is the only module on the import clientwright path that imports asyncio.
There is a second leak on the path you describe. capabilities_matrix() loads the httpx and httpx2 records from adapters/{httpx,httpx2}/capabilities.py, and both get the record from capabilities_for() in adapters/_httpx_shared.py. That module imports asyncio, ssl, both attempt engines and core.plan at the top. So a CI job that does ClientConfig(...) plus capabilities_matrix() pays for asyncio even after the first fix.
Plan:
core/policy/concurrency.py: import asyncio under TYPE_CHECKING for the annotations, and inside AsyncOriginLimiter._semaphore() at runtime. That method runs only inside a running event loop, so asyncio is already loaded by then and the local import is a sys.modules lookup. The constructor does not touch asyncio, so ClientRuntime can still build both limiters eagerly. Nothing in the API or the behaviour changes.
Move capabilities_for() from _httpx_shared to a new private module, adapters/_httpx_capabilities.py, which imports only core.capabilities and core.model. It sits outside the independence contract, like _httpx_shared. The two capability modules import from it, and _httpx_shared re-exports it so its own imports keep working.
Regression tests in tests/unit/adapters/test_bare_install.py, which already runs subprocess probes with every extra blocked. One asserts that import clientwright leaves asyncio out of sys.modules. The other runs your scenario, ClientConfig(...) plus capabilities_matrix(), and asserts the same.
Rejected:
Lazy loading at the package root (a module __getattr__ for ClientRuntime, ClientHandle and the rest). It would also fix the import time, but it makes the root harder to type-check and to read, and those classes are not the cost. asyncio is.
Moving AsyncOriginLimiter to core/engine/aio.py and building it only for async clients. This changes ClientRuntime construction, which is more churn for the same result.
A per-release import-time number in CI. Timing on shared runners is too noisy to gate on. Checking sys.modules is deterministic and fails on the exact regression.
The fix is in #63 and follows the plan above. The one difference: nothing imported capabilities_for from _httpx_shared once it moved, so I dropped it there rather than keeping a re-export.
After the change, import clientwright and your configuration-only path (ClientConfig(...) plus capabilities_matrix()) leave asyncio, ssl, socket and subprocess out of sys.modules. In a python:3.13-slim container with a bare install and precompiled bytecode (medians of 15 interleaved runs):
import + ClientConfig + capabilities_matrix(), wall time
30.6 ms
19.4 ms
That host is faster than yours, so compare the ratio: about a third of the import is gone, which matches the asyncio share in your trace.
On the CI guard, two subprocess probes in tests/unit/adapters/test_bare_install.py now assert that asyncio is not loaded, one after the import and one after your scenario. Both run on every PR, including the job with no extras installed. I didn't add a per-release timing check, because shared runners are too noisy to gate on.
Nothing changes on your side. The public API is the same, and async clients load asyncio when they first need it. The issue closes when #63 is merged and released.
Activity
AlexeyShalaev commentedon Oct 16, 2026
Reproduced on master (0.5.0, Python 3.13, macOS, warm bytecode cache):
import clientwrightis ~27 ms median there, and asyncio is ~9 ms of it, the same third as in your container numbers.core.policy.concurrencyis the only module on theimport clientwrightpath that imports asyncio.There is a second leak on the path you describe.
capabilities_matrix()loads the httpx and httpx2 records fromadapters/{httpx,httpx2}/capabilities.py, and both get the record fromcapabilities_for()inadapters/_httpx_shared.py. That module imports asyncio, ssl, both attempt engines andcore.planat the top. So a CI job that doesClientConfig(...)pluscapabilities_matrix()pays for asyncio even after the first fix.Plan:
core/policy/concurrency.py: import asyncio underTYPE_CHECKINGfor the annotations, and insideAsyncOriginLimiter._semaphore()at runtime. That method runs only inside a running event loop, so asyncio is already loaded by then and the local import is asys.moduleslookup. The constructor does not touch asyncio, soClientRuntimecan still build both limiters eagerly. Nothing in the API or the behaviour changes.capabilities_for()from_httpx_sharedto a new private module,adapters/_httpx_capabilities.py, which imports onlycore.capabilitiesandcore.model. It sits outside the independence contract, like_httpx_shared. The two capability modules import from it, and_httpx_sharedre-exports it so its own imports keep working.tests/unit/adapters/test_bare_install.py, which already runs subprocess probes with every extra blocked. One asserts thatimport clientwrightleaves asyncio out ofsys.modules. The other runs your scenario,ClientConfig(...)pluscapabilities_matrix(), and asserts the same.Rejected:
__getattr__forClientRuntime,ClientHandleand the rest). It would also fix the import time, but it makes the root harder to type-check and to read, and those classes are not the cost. asyncio is.AsyncOriginLimitertocore/engine/aio.pyand building it only for async clients. This changesClientRuntimeconstruction, which is more churn for the same result.sys.modulesis deterministic and fails on the exact regression.AlexeyShalaev commentedon Oct 16, 2026
The fix is in #63 and follows the plan above. The one difference: nothing imported
capabilities_forfrom_httpx_sharedonce it moved, so I dropped it there rather than keeping a re-export.After the change,
import clientwrightand your configuration-only path (ClientConfig(...)pluscapabilities_matrix()) leaveasyncio,ssl,socketandsubprocessout ofsys.modules. In apython:3.13-slimcontainer with a bare install and precompiled bytecode (medians of 15 interleaved runs):-X importtime, cumulativeclientwrightClientConfig+capabilities_matrix(), wall timeThat host is faster than yours, so compare the ratio: about a third of the import is gone, which matches the asyncio share in your trace.
On the CI guard, two subprocess probes in
tests/unit/adapters/test_bare_install.pynow assert that asyncio is not loaded, one after the import and one after your scenario. Both run on every PR, including the job with no extras installed. I didn't add a per-release timing check, because shared runners are too noisy to gate on.Nothing changes on your side. The public API is the same, and async clients load asyncio when they first need it. The issue closes when #63 is merged and released.