From e55a0fb2fe42b4967629dc7794a4bc8a6e07ed64 Mon Sep 17 00:00:00 2001 From: Alessio <148966056+alessio-locatelli@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:16:18 +0300 Subject: [PATCH] fix: resolve relative URLs against base_url in cache keys Cache keys were built from the URL as passed to the request, before aiohttp resolves a relative URL against the session's `base_url`. So `GET /x` got the same key for every base URL, and sessions sharing a cache backend returned each other's cached responses. Requests, `has_url()`, and `delete_url()` now resolve relative URLs first. `ClientSession._build_url()` isn't used for this because it's private and, in older aiohttp versions, asserts that the URL is relative whenever `base_url` is set. Cache entries saved for relative URLs won't be found after upgrading. Fixes https://github.com/requests-cache/aiohttp-client-cache/issues/422 --- HISTORY.md | 1 + aiohttp_client_cache/session.py | 20 ++++++++++++++++---- test/unit/test_session.py | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 628c4a6..674c86c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -8,6 +8,7 @@ configured JSON serializer, including bytes serializers. - Fixed cache key collisions between request bodies, such as `data=b'false'` and `json=False`, and between request bodies and headers. Existing cache entries won't be reused after upgrading. - Fixed cache key collisions between `data=` bodies that aiohttp sends differently, such as `b'x'` and `'x'`, `{'a': 1}` and `'a=1'`, `{'a': 'b&c=d'}` and `{'a': 'b', 'c': 'd'}`, `{'a': b'x'}` and `{'a': 'x'}`, or `{'a': [1, 2]}` and `{'a': '[1, 2]'}`. Header values are now URL-encoded in cache keys too. +- Fixed cache keys for relative URLs to include the session's `base_url`, so sessions with different base URLs no longer return each other's cached responses. Existing cache entries for relative URLs won't be reused after upgrading. - Added `CachedSession.has_url()` and `CachedSession.delete_url()`, which create keys like the session's requests: with its JSON serializer and, with `include_headers`, its default headers. - Requests with HTTP methods that aren't in `allowed_methods` now skip the cache entirely, so their JSON bodies are no longer serialized for a cache key. Added `CacheBackend.is_method_allowed()`. - Added `_ExpandedRequestOptions.refresh` to satisfy Pyright type checking. diff --git a/aiohttp_client_cache/session.py b/aiohttp_client_cache/session.py index 6942ee0..46facf6 100644 --- a/aiohttp_client_cache/session.py +++ b/aiohttp_client_cache/session.py @@ -12,6 +12,7 @@ from aiohttp import ClientSession from aiohttp.typedefs import StrOrURL +from yarl import URL from aiohttp_client_cache.backends import CacheBackend, get_valid_kwargs from aiohttp_client_cache.cache_control import CacheActions, ExpirationTime, compose_refresh_headers @@ -77,11 +78,12 @@ async def _request( # Attempt to fetch cached response headers = self._prepare_headers(kwargs.get('headers', None)) kwargs['headers'] = headers + url = self._resolve_url(str_or_url) key = self.cache.create_key( - method, str_or_url, json_serialize=self._cache_json_serialize, **kwargs + method, url, json_serialize=self._cache_json_serialize, **kwargs ) actions = self.cache.create_cache_actions( - key, str_or_url, expire_after=expire_after, refresh=refresh, **kwargs + key, url, expire_after=expire_after, refresh=refresh, **kwargs ) if actions.skip_read: @@ -169,11 +171,21 @@ async def _refresh_cached_response( async def delete_url(self, url: StrOrURL, method: str = 'GET', **kwargs: Any): """Like :py:meth:`.CacheBackend.delete_url`, with keys created like this session's requests""" - await self.cache.delete_url(url, method, **self._cache_key_kwargs(**kwargs)) + await self.cache.delete_url( + self._resolve_url(url), method, **self._cache_key_kwargs(**kwargs) + ) async def has_url(self, url: StrOrURL, method: str = 'GET', **kwargs: Any) -> bool: """Like :py:meth:`.CacheBackend.has_url`, with keys created like this session's requests""" - return await self.cache.has_url(url, method, **self._cache_key_kwargs(**kwargs)) + return await self.cache.has_url( + self._resolve_url(url), method, **self._cache_key_kwargs(**kwargs) + ) + + def _resolve_url(self, str_or_url: StrOrURL) -> URL: + url = URL(str_or_url) + if self._base_url and not url.is_absolute(): + return self._base_url.join(url) + return url def _cache_key_kwargs(self, **kwargs: Any) -> dict[str, Any]: return { diff --git a/test/unit/test_session.py b/test/unit/test_session.py index 78820ce..14e0b48 100644 --- a/test/unit/test_session.py +++ b/test/unit/test_session.py @@ -257,3 +257,29 @@ async def ok(request): assert await session.has_url(url) await session.delete_url(url) assert await cache.responses.size() == 0 + + +async def test_session__base_url_in_cache_key(aiohttp_server): + base_urls = [] + for text in ['a', 'b']: + + async def handler(request, text=text): + return web.Response(text=text) + + app = web.Application() + app.router.add_get('/x', handler) + base_urls.append((await aiohttp_server(app)).make_url('/')) + + cache = CacheBackend() + for base_url, expected_text in zip(base_urls, ['a', 'b'], strict=True): + async with CachedSession(base_url, cache=cache) as session: + response = await session.get('/x') + assert await response.text() == expected_text + assert response.from_cache is False + assert await session.has_url('/x') + assert await session.has_url(base_url / 'x') + + async with CachedSession(base_urls[0], cache=cache) as session: + await session.delete_url('/x') + assert not await cache.has_url(base_urls[0] / 'x') + assert await cache.has_url(base_urls[1] / 'x')