summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJordan Cook <jordan.cook@pioneer.com>2021-10-10 13:25:11 -0500
committerJordan Cook <jordan.cook@pioneer.com>2021-10-10 14:02:19 -0500
commitd9ea0f1391ce3fdcf9e38bda1979ed7877e5b44a (patch)
tree185169048d1b0b584fdef39c9505f8ea6255b7c3
parent6e10eb25a692cf4b240a628f14b2f3d31ae7e451 (diff)
downloadrequests-cache-d9ea0f1391ce3fdcf9e38bda1979ed7877e5b44a.tar.gz
Support immediate expiration + revalidation for Cache-Control: max-age=0 and Expires: 0
-rw-r--r--HISTORY.md2
-rw-r--r--requests_cache/cache_control.py15
-rw-r--r--tests/integration/base_cache_test.py28
-rw-r--r--tests/unit/test_cache_control.py15
4 files changed, 56 insertions, 4 deletions
diff --git a/HISTORY.md b/HISTORY.md
index f86478f..ccbfe96 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -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"""