diff --git a/HISTORY.md b/HISTORY.md index 2538981..5620d6f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,7 @@ ## Not released - Added `_ExpandedRequestOptions.refresh` to satisfy Pyright type checking. +- Revert `lru_cache` for request lock to avoid binding to multiple event loops (`RuntimeError: ...Lock is bound to a different event loop`) ## 0.14.3 (2026-01-07) diff --git a/aiohttp_client_cache/session.py b/aiohttp_client_cache/session.py index 2e92b45..a40fa9b 100644 --- a/aiohttp_client_cache/session.py +++ b/aiohttp_client_cache/session.py @@ -6,9 +6,9 @@ import warnings from asyncio import Lock from contextlib import asynccontextmanager -from functools import lru_cache from logging import getLogger from typing import TYPE_CHECKING, cast +from weakref import WeakValueDictionary from aiohttp import ClientSession from aiohttp.typedefs import StrOrURL @@ -49,11 +49,6 @@ async def __aexit__(self, *excinfo): from typing_extensions import Self -@lru_cache(maxsize=16384) -def _get_lock(_: int, __: str) -> Lock: - return Lock() - - class CacheMixin(MIXIN_BASE): """A mixin class for :py:class:`aiohttp.ClientSession` that adds caching support""" @@ -66,6 +61,8 @@ def __init__( **kwargs, ): self.cache = cache or CacheBackend() + # Drops a key's Lock as soon as nothing is contending for it + self._locks: WeakValueDictionary[str, Lock] = WeakValueDictionary() self._null_lock = nullcontext() # Pass along any valid kwargs for ClientSession (or custom session superclass) @@ -93,7 +90,10 @@ async def _request( if actions.skip_read: lock: Lock | nullcontext = self._null_lock else: - lock = _get_lock(id(self), key) + try: + lock = self._locks[key] + except KeyError: + lock = self._locks[key] = Lock() async with lock: response = await self.cache.request(actions) diff --git a/test/unit/test_session.py b/test/unit/test_session.py index 3a5c345..fb7f430 100644 --- a/test/unit/test_session.py +++ b/test/unit/test_session.py @@ -194,6 +194,22 @@ class CustomSession(CacheMixin, ClientSession): assert mock_request.called is False +@patch.object(ClientSession, '_request', return_value=FakeClientResponse) +async def test_session__locks_do_not_leak(mock_request): + """Locks are only needed while requests are actively contending for a cache key, so distinct + keys should not accumulate indefinitely. + """ + cache = MagicMock(spec=CacheBackend) + cache.request.return_value = None + cache.create_key.side_effect = lambda method, url, **kwargs: str(url) + + async with CachedSession(cache=cache) as session: + for i in range(1000): + await session.get(f'http://test.url/{i}') + + assert len(session._locks) == 0 + + @patch.object(ClientSession, '_request', return_value=FakeCachedResponse) async def test_session__cache_include_headers(mock_request): async with CachedSession(cache=CacheBackend(include_headers=True)) as session: