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
-
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!)
-
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())
-
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:
-
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.
-
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!
-
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
-
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).
-
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.
-
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.
Python -VV
Pip Freeze
Reproduction Steps
Instantiate
Mistralinside a standard synchronous context manager:Or conversely, instantiate
Mistralinside an asynchronous context manager: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 finalizerweakref.finalize(self, close_clients, ...).Expected Behavior
with Mistral(...) as client:, all internal HTTP clients and transports created byMistral.__init__should be properly and deterministically closed.async with Mistral(...) as client:, all internal HTTP clients and transports created byMistral.__init__should be properly closed.httpx.AsyncClient.httpx.Client.Actual Behavior
There is an architectural mismatch between eager client initialization and asymmetric context manager teardown:
Eager dual-client instantiation in
src/mistralai/client/sdk.py(lines 105–113):Every single
Mistral()instance always allocates BOTH a synchronoushttpx.Clientand an asynchronoushttpx.AsyncClient, doubling socket/transport overhead.Asymmetric context manager teardown in
src/mistralai/client/sdk.py(lines 217–232):__exit__only closesclient, leavingasync_clientopen!__aexit__only closesasync_client, leavingclientopen!Fallback to fragile finalizer: Because one client is always left unclosed, the object relies on
weakref.finalize(self, close_clients, ...)insrc/mistralai/client/httpclient.py. When GC or process shutdown runs,close_clientsattemptsasyncio.run(async_client.aclose())to clean up an async client the sync user never touched.Additional Context
Relationship to [BUG CLIENT]: Process exits with "Too many open files" when many Mistral instances are created and finalized on Python 3.14 #509:
In issue [BUG CLIENT]: Process exits with "Too many open files" when many Mistral instances are created and finalized on Python 3.14 #509 ("Process exits with 'Too many open files' when many Mistral instances are created and finalized on Python 3.14"), the reporter documented that
close_clientscreates event loops viaasyncio.run()during GC at interpreter exit, triggering socketpair leaks andEMFILE/Too many open files.The reporter assumed deterministic context-manager teardown was missing. However, context managers do exist — but because of the asymmetric cleanup, even users diligently using
with Mistral(...) as client:still leakasync_client, triggering the exact same finalizer cascade!Runtime complications with active event loops:
In environments like FastAPI, Celery, or Jupyter notebooks where an event loop is actively running, invoking
asyncio.run(async_client.aclose())inside a finalizer raisesRuntimeError: Cannot run the event loop while another loop is runningor causes unhandled coroutine warnings.Suggested Solutions
Lazy Client Instantiation (Recommended):
Avoid creating
httpx.Clientandhttpx.AsyncClientupfront inMistral.__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).AsyncClient, completely eliminating theclose_clientsfinalizer dance and the [BUG CLIENT]: Process exits with "Too many open files" when many Mistral instances are created and finalized on Python 3.14 #509 socket leak.Client.Symmetric Teardown:
__exit__: Closeclient.close(), and ifasync_clientwas created, close its underlying transport synchronously or cancel it cleanly.__aexit__: Awaitasync_client.aclose(), and also callclient.close()if initialized.Explicit
.close()and.aclose()methods:Provide public
close()andaclose()methods onMistralso non-context-managed lifecycles can be explicitly closed without relying on GC finalizers.