AWHR: AI Written, Human Reviewed by me
Environment
- google-genai 1.75.0 (the
__del__ and aiohttp selection are unchanged on main as of today)
- aiohttp 3.13.4
- CPython 3.14.7, Linux (uvicorn API process)
What happens
BaseApiClient.__del__ (and AsyncHttpxClient.__del__) do:
asyncio.get_running_loop().create_task(self.aclose())
That schedules the close on whichever loop is running on the thread where garbage collection happens to fire, not on the loop that created the aiohttp ClientSession. In our process a second thread runs its own event loop (weaviate-client's sync client does this; any loop.run_forever() helper thread will), so GC regularly runs there.
On Python 3.13 this was a swallowed warning. On 3.14 asyncio's current-task bookkeeping moved onto the thread state, and aiohttp's BaseConnector.close() starts its cleanup with asyncio.Task(..., loop=self._loop, eager_start=True). When self._loop is not the calling thread's running loop, 3.14 raises RuntimeError: loop <...> is not the running loop from inside the eager start, and asyncio then logs:
Task was destroyed but it is pending!
task: <Task pending name='Task-...' coro=<_wait_for_close() running at .../aiohttp/connector.py:136> ...>
once per garbage-collected client. We see this on every deploy since moving to 3.14.
Minimal reproduction of the 3.14 behavior (no API key needed)
import asyncio, threading, aiohttp
from aiohttp import web
async def handler(request):
return web.Response(text="ok")
def start_bg_loop():
loop = asyncio.new_event_loop()
threading.Thread(target=loop.run_forever, daemon=True).start()
return loop
async def main():
app = web.Application(); app.router.add_get("/", handler)
runner = web.AppRunner(app); await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0); await site.start()
port = site._server.sockets[0].getsockname()[1]
session = aiohttp.ClientSession() # created on the main loop
async with session.get(f"http://127.0.0.1:{port}/") as resp:
await resp.text()
bg = start_bg_loop()
fut = asyncio.run_coroutine_threadsafe(session.close(), bg) # what __del__ effectively does
await asyncio.wrap_future(fut)
await asyncio.sleep(0.2)
await runner.cleanup()
asyncio.run(main())
Python 3.13: RuntimeError: Task ... attached to a different loop. Python 3.14: RuntimeError: loop ... is not the running loop plus the Task was destroyed but it is pending! log above.
Suggested fix
Remember the loop the session was created on and close on that loop (loop.call_soon_threadsafe(loop.create_task, self.aclose()) when get_running_loop() differs, or skip the close entirely when the owning loop is closed), rather than using whatever loop is current in __del__. Alternatively, drop the __del__-time close and rely on aclose()/context managers, which is what aiohttp itself recommends.
Workaround we're using
Passing a custom transport in HttpOptions.async_client_args makes _use_aiohttp() return False, so the client stays on httpx and the cross-loop close is harmless. It would help to document this as the supported way to opt out of aiohttp.
AWHR: AI Written, Human Reviewed by me
Environment
__del__and aiohttp selection are unchanged onmainas of today)What happens
BaseApiClient.__del__(andAsyncHttpxClient.__del__) do:That schedules the close on whichever loop is running on the thread where garbage collection happens to fire, not on the loop that created the aiohttp
ClientSession. In our process a second thread runs its own event loop (weaviate-client's sync client does this; anyloop.run_forever()helper thread will), so GC regularly runs there.On Python 3.13 this was a swallowed warning. On 3.14 asyncio's current-task bookkeeping moved onto the thread state, and aiohttp's
BaseConnector.close()starts its cleanup withasyncio.Task(..., loop=self._loop, eager_start=True). Whenself._loopis not the calling thread's running loop, 3.14 raisesRuntimeError: loop <...> is not the running loopfrom inside the eager start, and asyncio then logs:once per garbage-collected client. We see this on every deploy since moving to 3.14.
Minimal reproduction of the 3.14 behavior (no API key needed)
Python 3.13:
RuntimeError: Task ... attached to a different loop. Python 3.14:RuntimeError: loop ... is not the running loopplus theTask was destroyed but it is pending!log above.Suggested fix
Remember the loop the session was created on and close on that loop (
loop.call_soon_threadsafe(loop.create_task, self.aclose())whenget_running_loop()differs, or skip the close entirely when the owning loop is closed), rather than using whatever loop is current in__del__. Alternatively, drop the__del__-time close and rely onaclose()/context managers, which is what aiohttp itself recommends.Workaround we're using
Passing a custom transport in
HttpOptions.async_client_argsmakes_use_aiohttp()returnFalse, so the client stays on httpx and the cross-loop close is harmless. It would help to document this as the supported way to opt out of aiohttp.