From ee0a7091675d1e4eaa9c5de314a54a593503e3fe Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 5 Aug 2026 13:30:50 -0500 Subject: [PATCH 1/2] Revert lru_cache for request lock to avoid binding to multiple event loops --- aiohttp_client_cache/session.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/aiohttp_client_cache/session.py b/aiohttp_client_cache/session.py index 2e92b45..15e42f5 100644 --- a/aiohttp_client_cache/session.py +++ b/aiohttp_client_cache/session.py @@ -5,8 +5,8 @@ import sys import warnings from asyncio import Lock +from collections import defaultdict from contextlib import asynccontextmanager -from functools import lru_cache from logging import getLogger from typing import TYPE_CHECKING, cast @@ -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,7 @@ def __init__( **kwargs, ): self.cache = cache or CacheBackend() + self._locks: dict[str, Lock] = defaultdict(Lock) self._null_lock = nullcontext() # Pass along any valid kwargs for ClientSession (or custom session superclass) @@ -93,7 +89,7 @@ async def _request( if actions.skip_read: lock: Lock | nullcontext = self._null_lock else: - lock = _get_lock(id(self), key) + lock = self._locks[key] async with lock: response = await self.cache.request(actions) From 9028e26c59edbd6463a17cfb949587e136833c22 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 5 Aug 2026 13:41:41 -0500 Subject: [PATCH 2/2] Use WeakValueDictionary to drop a key's Lock as soon as nothing is contending for it --- HISTORY.md | 1 + aiohttp_client_cache/session.py | 10 +++++++--- test/unit/test_session.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) 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 15e42f5..a40fa9b 100644 --- a/aiohttp_client_cache/session.py +++ b/aiohttp_client_cache/session.py @@ -5,10 +5,10 @@ import sys import warnings from asyncio import Lock -from collections import defaultdict from contextlib import asynccontextmanager from logging import getLogger from typing import TYPE_CHECKING, cast +from weakref import WeakValueDictionary from aiohttp import ClientSession from aiohttp.typedefs import StrOrURL @@ -61,7 +61,8 @@ def __init__( **kwargs, ): self.cache = cache or CacheBackend() - self._locks: dict[str, Lock] = defaultdict(Lock) + # 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) @@ -89,7 +90,10 @@ async def _request( if actions.skip_read: lock: Lock | nullcontext = self._null_lock else: - lock = self._locks[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: