Skip to content

import clientwright takes ~190 ms on Linux; ~80 ms of it is asyncio, pulled in eagerly by core.policy.concurrency #62

Closed

Description

@AlexeyShalaev

Version: clientwright 0.5.0, Python 3.13.

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:

Package Import, ms
deadline_budget 18, 16, 17, 16, 21
clientwright 381, 181, 189, 200, 184
servicewright 230, 225, 228, 265, 235

On Windows the same import took about 500 ms.

python -X importtime -c "import clientwright" (cumulative, µs):

200842 | clientwright
 93279 |   clientwright.core.plan
 83994 |   clientwright.core.capabilities
 80610 |     clientwright.core.policy.concurrency
 80003 |       asyncio
 70208 |         asyncio.base_events
 30322 |     clientwright.core.config
 24075 |           ssl

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.

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    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:

    1. 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.
    2. 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.
    3. 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.
  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    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):

    master #63
    -X importtime, cumulative clientwright 30.4 ms 19.3 ms
    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions