Skip to content

[BUG CLIENT]: Eager dual-client instantiation and asymmetric __exit__/__aexit__ leaks unclosed HTTP clients #626

Description

@andriiorap

Python -VV

Python 3.11.8 (default, Feb 12 2024, 14:50:05) [Clang 15.0.0 (clang-1500.1.0.2.5)]
(Also affects Python 3.9, 3.10, 3.12, 3.13, and 3.14)

Pip Freeze

mistralai==2.10.0
httpx>=0.27.0
pydantic>=2.0.0

Reproduction Steps

  1. Instantiate Mistral inside a standard synchronous context manager:

    from mistralai import Mistral
    
    with Mistral(api_key="mock-key") as client:
        # do sync work
        pass
    
    # Inspect SDK internals after exiting context manager:
    print("Sync client:", client.sdk_configuration.client)          # None (closed)
    print("Async client:", client.sdk_configuration.async_client)   # Still <httpx.AsyncClient object> (ALIVE & UNCLOSED!)
  2. Or conversely, instantiate Mistral inside an asynchronous context manager:

    import asyncio
    from mistralai import Mistral
    
    async def main():
        async with Mistral(api_key="mock-key") as client:
            # do async work
            pass
    
        # Inspect SDK internals after exiting async context manager:
        print("Sync client:", client.sdk_configuration.client)        # Still <httpx.Client object> (ALIVE & UNCLOSED!)
        print("Async client:", client.sdk_configuration.async_client) # None (closed)
    
    asyncio.run(main())
  3. In loops, worker pools, or microservice handlers creating Mistral(...) instances, notice that unclosed clients accumulate, leaving connection pools, transports, and open file descriptors to be reaped by garbage collection and the fallback finalizer weakref.finalize(self, close_clients, ...).

Expected Behavior

  • When using with Mistral(...) as client:, all internal HTTP clients and transports created by Mistral.__init__ should be properly and deterministically closed.
  • When using async with Mistral(...) as client:, all internal HTTP clients and transports created by Mistral.__init__ should be properly closed.
  • Sync-only workflows should not allocate or leave alive an unclosed httpx.AsyncClient.
  • Async-only workflows should not allocate or leave alive an unclosed httpx.Client.

Actual Behavior

There is an architectural mismatch between eager client initialization and asymmetric context manager teardown:

  1. Eager dual-client instantiation in src/mistralai/client/sdk.py (lines 105–113):

    if client is None:
        client = httpx.Client(follow_redirects=True)
        client_supplied = False
    
    if async_client is None:
        async_client = httpx.AsyncClient(follow_redirects=True)
        async_client_supplied = False

    Every single Mistral() instance always allocates BOTH a synchronous httpx.Client and an asynchronous httpx.AsyncClient, doubling socket/transport overhead.

  2. Asymmetric context manager teardown in src/mistralai/client/sdk.py (lines 217–232):

    def __exit__(self, exc_type, exc_val, exc_tb):
        if (
            self.sdk_configuration.client is not None
            and not self.sdk_configuration.client_supplied
        ):
            self.sdk_configuration.client.close()
        self.sdk_configuration.client = None
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if (
            self.sdk_configuration.async_client is not None
            and not self.sdk_configuration.async_client_supplied
        ):
            await self.sdk_configuration.async_client.aclose()
        self.sdk_configuration.async_client = None
    • __exit__ only closes client, leaving async_client open!
    • __aexit__ only closes async_client, leaving client open!
  3. Fallback to fragile finalizer: Because one client is always left unclosed, the object relies on weakref.finalize(self, close_clients, ...) in src/mistralai/client/httpclient.py. When GC or process shutdown runs, close_clients attempts asyncio.run(async_client.aclose()) to clean up an async client the sync user never touched.

Additional Context

Suggested Solutions

  1. Lazy Client Instantiation (Recommended):
    Avoid creating httpx.Client and httpx.AsyncClient upfront in Mistral.__init__. Instead, lazily initialize them when a sync or async API call is first made (or based on whether sync or async sub-SDK methods are invoked).

  2. Symmetric Teardown:

    • In __exit__: Close client.close(), and if async_client was created, close its underlying transport synchronously or cancel it cleanly.
    • In __aexit__: Await async_client.aclose(), and also call client.close() if initialized.
  3. Explicit .close() and .aclose() methods:
    Provide public close() and aclose() methods on Mistral so non-context-managed lifecycles can be explicitly closed without relying on GC finalizers.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions