diff options
author | Jordan Cook <jordan.cook@pioneer.com> | 2021-10-10 13:25:11 -0500 |
---|---|---|
committer | Jordan Cook <jordan.cook@pioneer.com> | 2021-10-10 14:02:19 -0500 |
commit | d9ea0f1391ce3fdcf9e38bda1979ed7877e5b44a (patch) | |
tree | 185169048d1b0b584fdef39c9505f8ea6255b7c3 | |
parent | 6e10eb25a692cf4b240a628f14b2f3d31ae7e451 (diff) | |
download | requests-cache-d9ea0f1391ce3fdcf9e38bda1979ed7877e5b44a.tar.gz |
Support immediate expiration + revalidation for Cache-Control: max-age=0 and Expires: 0
-rw-r--r-- | HISTORY.md | 2 | ||||
-rw-r--r-- | requests_cache/cache_control.py | 15 | ||||
-rw-r--r-- | tests/integration/base_cache_test.py | 28 | ||||
-rw-r--r-- | tests/unit/test_cache_control.py | 15 |
4 files changed, 56 insertions, 4 deletions
@@ -7,6 +7,8 @@ * Make per-request expiration thread-safe for both `CachedSession.request()` and `CachedSession.send()` * Handle some additional corner cases when normalizing request data * Some micro-optimizations for request matching +* Fix issue with cache headers not being used correctly if `cache_control=True` is used with an `expire_after` value +* Support immediate expiration + revalidation for `Cache-Control: max-age=0` and `Expires: 0` * Fix license metadata as shown on PyPI ## 0.8.1 (2021-09-15) diff --git a/requests_cache/cache_control.py b/requests_cache/cache_control.py index 909ff5c..2cad7b4 100644 --- a/requests_cache/cache_control.py +++ b/requests_cache/cache_control.py @@ -136,12 +136,23 @@ class CacheActions: directives = get_cache_directives(response.headers) logger.debug(f'Cache directives from response headers: {directives}') - # Check expiration headers and conditions for writing to the cache + # Check expiration headers self.expire_after = coalesce( directives.get('max-age'), directives.get('expires'), self.expire_after ) + + # If expiration is 0 and there's a validator, save it to the cache and revalidate on use + has_validator = response.headers.get('ETag') or response.headers.get('Last-Modified') + if self.expire_after == DO_NOT_CACHE and has_validator: + self.expire_after = datetime.utcnow() + + # Check conditions for writing to the cache self.skip_write = any( - [self.expire_after == DO_NOT_CACHE, 'no-store' in directives, self.skip_write] + [ + self.expire_after == DO_NOT_CACHE, + 'no-store' in directives, + self.skip_write, + ] ) diff --git a/tests/integration/base_cache_test.py b/tests/integration/base_cache_test.py index b435b4f..8f1c5f2 100644 --- a/tests/integration/base_cache_test.py +++ b/tests/integration/base_cache_test.py @@ -5,11 +5,12 @@ from io import BytesIO from threading import Thread from time import sleep, time from typing import Dict, Type +from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse import pytest import requests -from requests.models import PreparedRequest +from requests import PreparedRequest, Session from requests_cache import ALL_METHODS, CachedResponse, CachedSession from requests_cache.backends.base import BaseCache @@ -168,7 +169,7 @@ class BaseCacheTest: ({'ETag': ETAG, 'Last-Modified': LAST_MODIFIED}, True), ], ) - def test_304_not_modified(self, cached_response_headers, expected_from_cache): + def test_conditional_request(self, cached_response_headers, expected_from_cache): """Test behavior of ETag and Last-Modified headers and 304 responses. When a cached response contains one of these headers, corresponding request headers should @@ -184,6 +185,29 @@ class BaseCacheTest: response = session.get(httpbin('cache')) assert response.from_cache == expected_from_cache + def test_conditional_request__max_age_0(self): + """With both max-age=0 and a validator, the response should be saved and revalidated on next + request + """ + url = httpbin('response-headers') + response_headers = {'Cache-Control': 'max-age=0', 'ETag': ETAG} + session = self.init_session(cache_control=True) + + # This endpoint returns request params as response headers + session.get(url, params=response_headers) + + # It doesn't respond to conditional requests, but let's just pretend it does + with patch.object(Session, 'send', return_value=MagicMock(status_code=304)): + response = session.get(url, params=response_headers) + + assert response.from_cache is True + assert response.is_expired is True + + # cache_key = list(session.cache.responses.keys())[0] + # response = session.cache.responses[cache_key] + # response.request.url = httpbin(f'etag/{ETAG}') + # response = session.get(httpbin('response-headers'), params=response_headers) + @pytest.mark.parametrize('stream', [True, False]) def test_response_decode(self, stream): """Test that gzip-compressed raw responses (including streamed responses) can be manually diff --git a/tests/unit/test_cache_control.py b/tests/unit/test_cache_control.py index d2c6233..2388f3d 100644 --- a/tests/unit/test_cache_control.py +++ b/tests/unit/test_cache_control.py @@ -238,6 +238,21 @@ def test_update_from_response__ignored(): assert actions.expire_after is None +@patch('requests_cache.cache_control.datetime') +def test_update_from_response__revalidate(mock_datetime): + """If expiration is 0 and there's a validator the response should be cached and revalidated""" + url = 'https://img.site.com/base/img.jpg' + actions = CacheActions.from_request( + cache_key='key', + request=MagicMock(url=url), + cache_control=True, + ) + actions.update_from_response( + MagicMock(url=url, headers={'Cache-Control': 'max-age=0', 'ETag': ETAG}) + ) + assert actions.expire_after == mock_datetime.utcnow() + + @pytest.mark.parametrize('directive', IGNORED_DIRECTIVES) def test_ignored_headers(directive): """Ensure that currently unimplemented Cache-Control headers do not affect behavior""" |