summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJordan Cook <jordan.cook@pioneer.com>2022-05-21 12:54:56 -0500
committerJordan Cook <jordan.cook@pioneer.com>2022-06-16 15:45:42 -0500
commit20dcc26d7d49d2ec8ee9571161a2722bb09fed25 (patch)
treee99bc0a4597004cf6eb63ed26e6d980e0b0c1064
parentccaf6b0b2d9a7dc612b5129e1c2841a04a2b587c (diff)
downloadrequests-cache-20dcc26d7d49d2ec8ee9571161a2722bb09fed25.tar.gz
Add support for Vary
-rw-r--r--requests_cache/backends/base.py6
-rw-r--r--requests_cache/policy/actions.py39
-rw-r--r--requests_cache/session.py11
-rw-r--r--tests/unit/policy/test_actions.py80
4 files changed, 129 insertions, 7 deletions
diff --git a/requests_cache/backends/base.py b/requests_cache/backends/base.py
index 5e00db0..2c5b0b6 100644
--- a/requests_cache/backends/base.py
+++ b/requests_cache/backends/base.py
@@ -104,13 +104,15 @@ class BaseCache:
self.responses.close()
self.redirects.close()
- def create_key(self, request: AnyRequest = None, **kwargs) -> str:
+ def create_key(
+ self, request: AnyRequest = None, match_headers: Iterable[str] = None, **kwargs
+ ) -> str:
"""Create a normalized cache key from a request object"""
key_fn = self._settings.key_fn or create_key
return key_fn(
request=request,
ignored_parameters=self._settings.ignored_parameters,
- match_headers=self._settings.match_headers,
+ match_headers=match_headers or self._settings.match_headers,
serializer=self.responses.serializer,
**kwargs,
)
diff --git a/requests_cache/policy/actions.py b/requests_cache/policy/actions.py
index 707b0d7..ad201f9 100644
--- a/requests_cache/policy/actions.py
+++ b/requests_cache/policy/actions.py
@@ -17,7 +17,7 @@ from . import (
get_expiration_seconds,
get_url_expiration,
)
-from .settings import CacheSettings
+from .settings import CacheSettings, KeyCallback
if TYPE_CHECKING:
from ..models import CachedResponse
@@ -64,6 +64,7 @@ class CacheActions(RichMixin):
# Temporary attributes
_only_if_cached: bool = field(default=False, repr=False)
_refresh: bool = field(default=False, repr=False)
+ _request: PreparedRequest = field(default=None, repr=False)
_stale_if_error: Union[bool, ExpirationTime] = field(default=None, repr=False)
_stale_while_revalidate: Union[bool, ExpirationTime] = field(default=None, repr=False)
_validation_headers: Dict[str, str] = field(factory=dict, repr=False)
@@ -76,6 +77,10 @@ class CacheActions(RichMixin):
indicate a user-requested refresh. Typically that's only used in response headers, and
`max-age=0` would be used by a client to request a refresh. However, this would conflict
with the `expire_after` option provided in :py:meth:`.CachedSession.request`.
+
+ Args:
+ request: The outgoing request
+ settings: Session-level cache settings
"""
settings = settings or CacheSettings()
directives = CacheDirectives.from_headers(request.headers)
@@ -107,15 +112,16 @@ class CacheActions(RichMixin):
actions = cls(
cache_key=cache_key,
+ directives=directives,
expire_after=expire_after,
only_if_cached=only_if_cached,
refresh=refresh,
+ request=request,
+ settings=settings,
skip_read=any(read_criteria.values()),
skip_write=directives.no_store,
stale_if_error=stale_if_error,
stale_while_revalidate=stale_while_revalidate,
- directives=directives,
- settings=settings,
)
return actions
@@ -155,11 +161,18 @@ class CacheActions(RichMixin):
return datetime.utcnow() < cached_response.expires + offset
- def update_from_cached_response(self, cached_response: 'CachedResponse'):
+ def update_from_cached_response(
+ self, cached_response: 'CachedResponse', create_key: KeyCallback = None, **key_kwargs
+ ):
"""Determine if we can reuse a cached response, or set headers for a conditional request
if possible.
Used after fetching a cached response, but before potentially sending a new request.
+
+ Args:
+ cached_response: Cached response to examine
+ create_key: Cache key function, used for validating ``Vary`` headers
+ key_kwargs: Additional keyword arguments for ``create_key``.
"""
usable_response = self.is_usable(cached_response)
usable_if_error = self.is_usable(cached_response, error=True)
@@ -170,6 +183,9 @@ class CacheActions(RichMixin):
# Send the request for the first time
elif cached_response is None:
self.send_request = True
+ # If response contains Vary and doesn't match, consider it a cache miss
+ elif create_key and not self._validate_vary(cached_response, create_key, **key_kwargs):
+ self.send_request = True
# Resend the request, unless settings permit a stale response
elif not usable_response and not (self._only_if_cached and usable_if_error):
self.resend_request = True
@@ -262,6 +278,21 @@ class CacheActions(RichMixin):
self.send_request = True
self.resend_request = False
+ def _validate_vary(
+ self, cached_response: 'CachedResponse', create_key: KeyCallback, **key_kwargs
+ ) -> bool:
+ """If the cached response contains Vary, check that the specified request headers match"""
+ vary = cached_response.headers.get('Vary')
+ if not vary:
+ return True
+ elif vary == '*':
+ return False
+
+ # Generate a secondary cache key based on Vary for both the cached request and new request
+ key_kwargs['match_headers'] = [k.strip() for k in vary.split(',')]
+ vary_cache_key = create_key(cached_response.request, **key_kwargs)
+ return create_key(self._request, **key_kwargs) == vary_cache_key
+
def _log_cache_criteria(operation: str, criteria: Dict):
"""Log details on any failed checks for cache read or write"""
diff --git a/requests_cache/session.py b/requests_cache/session.py
index b65da1e..f3dec60 100644
--- a/requests_cache/session.py
+++ b/requests_cache/session.py
@@ -192,7 +192,16 @@ class CacheMixin(MIXIN_BASE):
cached_response: Optional[CachedResponse] = None
if not actions.skip_read:
cached_response = self.cache.get_response(actions.cache_key)
- actions.update_from_cached_response(cached_response)
+ actions.update_from_cached_response(cached_response, self.cache.create_key, **kwargs)
+
+ # TODO: Does this fit better here, or in CacheActions?
+ # If response contains Vary, check that the specified request headers match
+ # if cached_response and cached_response.headers.get('Vary'):
+ # vary = cached_response.headers['Vary']
+ # new_cache_key = self.cache.create_key(request, match_headers=vary)
+ # vary_cache_key = self.cache.create_key(cached_response.request, match_headers=vary)
+ # if new_cache_key != vary_cache_key:
+ # cached_response = None
# Handle missing and expired responses based on settings and headers
if actions.error_504:
diff --git a/tests/unit/policy/test_actions.py b/tests/unit/policy/test_actions.py
index 317ecc8..f5d4c4a 100644
--- a/tests/unit/policy/test_actions.py
+++ b/tests/unit/policy/test_actions.py
@@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from requests import PreparedRequest, Request
+from requests_cache.cache_keys import create_key
from requests_cache.models import CachedResponse
from requests_cache.policy.actions import EXPIRE_IMMEDIATELY, CacheActions
from requests_cache.policy.settings import CacheSettings
@@ -235,6 +236,85 @@ def test_update_from_cached_response__stale_while_revalidate():
assert actions.resend_async is True
+@pytest.mark.parametrize(
+ 'vary, cached_headers, new_headers, expected_match',
+ [
+ ({}, {}, {}, True),
+ ({'Vary': 'Accept'}, {'Accept': 'application/json'}, {'Accept': 'application/json'}, True),
+ ({'Vary': 'Accept'}, {'Accept': 'application/json'}, {}, False),
+ (
+ {'Vary': 'Accept'},
+ {'Accept': 'application/json'},
+ {'Accept': 'application/json', 'Accept-Language': 'en'},
+ True,
+ ),
+ (
+ {'Vary': 'Accept-Encoding'},
+ {'Accept': 'application/json'},
+ {'Accept': 'text/html'},
+ True,
+ ),
+ ({'Vary': 'Accept'}, {'Accept': 'application/json'}, {'Accept': 'text/html'}, False),
+ (
+ {'Vary': 'Accept-Encoding'},
+ {'Accept-Encoding': 'gzip,deflate'},
+ {'Accept-Encoding': 'gzip,deflate'},
+ True,
+ ),
+ # Only basic header normalization is done in create_key() (whitespace, case, order)
+ (
+ {'Vary': 'Accept-Encoding'},
+ {'Accept-Encoding': 'gzip,deflate'},
+ {'Accept-Encoding': 'dEfLaTe, GZIP, '},
+ True,
+ ),
+ (
+ {'Vary': 'Accept-Encoding'},
+ {'Accept-Encoding': 'gzip,deflate'},
+ {'Accept-Encoding': 'gzip,br'},
+ False,
+ ),
+ (
+ {'Vary': 'Accept, Accept-Encoding'},
+ {'Accept': 'application/json', 'Accept-Encoding': 'gzip,deflate'},
+ {'Accept': 'application/json', 'Accept-Encoding': 'gzip,deflate'},
+ True,
+ ),
+ (
+ {'Vary': 'Accept, Accept-Encoding'},
+ {'Accept': 'application/json', 'Accept-Encoding': 'gzip,deflate'},
+ {'Accept': 'application/json', 'Accept-Encoding': 'br'},
+ False,
+ ),
+ (
+ {'Vary': 'Accept, Accept-Encoding'},
+ {'Accept': 'application/json', 'Accept-Encoding': 'gzip,deflate'},
+ {'Accept': 'text/html', 'Accept-Encoding': 'gzip,deflate'},
+ False,
+ ),
+ (
+ {'Vary': 'Accept, Accept-Encoding'},
+ {'Accept': 'application/json', 'Accept-Encoding': 'gzip,deflate'},
+ {'Accept-Encoding': 'gzip,deflate'},
+ False,
+ ),
+ ({'Vary': '*'}, {}, {}, False),
+ ({'Vary': '*'}, {'Accept': 'application/json'}, {'Accept': 'application/json'}, False),
+ ],
+)
+def test_update_from_cached_response__vary(vary, cached_headers, new_headers, expected_match):
+ cached_response = CachedResponse(
+ headers=vary,
+ request=Request(method='GET', url='https://site.com/img.jpg', headers=cached_headers),
+ )
+ request = Request(method='GET', url='https://site.com/img.jpg', headers=new_headers)
+ actions = CacheActions.from_request('key', request)
+ actions.update_from_cached_response(cached_response, create_key=create_key)
+
+ # If the headers don't match wrt. Vary, expect a new request to be sent (cache miss)
+ assert actions.send_request is not expected_match
+
+
@pytest.mark.parametrize('max_stale, usable', [(5, False), (15, True)])
def test_is_usable__max_stale(max_stale, usable):
"""For a response that expired 10 seconds ago, it may be either accepted or rejected based on