diff options
author | Jordan Cook <jordan.cook@pioneer.com> | 2022-05-21 12:54:56 -0500 |
---|---|---|
committer | Jordan Cook <jordan.cook@pioneer.com> | 2022-06-16 15:45:42 -0500 |
commit | 20dcc26d7d49d2ec8ee9571161a2722bb09fed25 (patch) | |
tree | e99bc0a4597004cf6eb63ed26e6d980e0b0c1064 | |
parent | ccaf6b0b2d9a7dc612b5129e1c2841a04a2b587c (diff) | |
download | requests-cache-20dcc26d7d49d2ec8ee9571161a2722bb09fed25.tar.gz |
Add support for Vary
-rw-r--r-- | requests_cache/backends/base.py | 6 | ||||
-rw-r--r-- | requests_cache/policy/actions.py | 39 | ||||
-rw-r--r-- | requests_cache/session.py | 11 | ||||
-rw-r--r-- | tests/unit/policy/test_actions.py | 80 |
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 |