blob: 4ea133c0f98fdee7f907e7debc6c2bdcae2ffc46 [file] [edit]
from datetime import datetime, timedelta
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
from tests.conftest import ETAG, HTTPDATE_STR, LAST_MODIFIED, MOCKED_URL, get_mock_response
IGNORED_DIRECTIVES = [
'no-transform',
'private',
'proxy-revalidate',
'public',
's-maxage=<seconds>',
]
BASIC_REQUEST = Request(method='GET', url='https://site.com/img.jpg', headers={})
EXPIRED_RESPONSE = CachedResponse(expires=datetime.utcnow() - timedelta(1))
@pytest.mark.parametrize(
'request_expire_after, url_expire_after, expected_expiration',
[
(2, 3, 2),
(None, 3, 3),
(2, None, 2),
(None, None, 1),
],
)
@patch('requests_cache.policy.actions.get_url_expiration')
def test_init(
get_url_expiration,
request_expire_after,
url_expire_after,
expected_expiration,
):
"""Test precedence with various combinations or per-request, per-session, per-URL, and
Cache-Control expiration
"""
request = PreparedRequest()
request.url = 'https://img.site.com/base/img.jpg'
if request_expire_after:
request.headers = {'Cache-Control': f'max-age={request_expire_after}'}
get_url_expiration.return_value = url_expire_after
settings = CacheSettings(cache_control=True, expire_after=1)
actions = CacheActions.from_request(cache_key='key', request=request, settings=settings)
assert actions.expire_after == expected_expiration
@pytest.mark.parametrize(
'headers, expected_expiration',
[
({}, None),
({'Expires': HTTPDATE_STR}, None), # Only valid for response headers
({'Cache-Control': 'max-age=60'}, 60),
({'Cache-Control': 'public, max-age=60'}, 60),
({'Cache-Control': b'public, max-age=60'}, 60), # requests-oauthlib casts headers to bytes
({'Cache-Control': 'max-age=0'}, EXPIRE_IMMEDIATELY),
],
)
def test_init_from_headers(headers, expected_expiration):
"""Test with Cache-Control request headers"""
settings = CacheSettings(cache_control=True)
request = Request(method='GET', url=MOCKED_URL, headers=headers).prepare()
actions = CacheActions.from_request('key', request, settings)
assert actions.cache_key == 'key'
if expected_expiration != EXPIRE_IMMEDIATELY:
assert actions.expire_after == expected_expiration
assert actions.skip_read is False
assert actions.skip_write is False
def test_init_from_headers__no_store():
"""Test with Cache-Control request headers"""
settings = CacheSettings(cache_control=True)
request = Request(method='GET', url=MOCKED_URL, headers={'Cache-Control': 'no-store'}).prepare()
actions = CacheActions.from_request('key', request, settings)
assert actions.skip_read is True
assert actions.skip_write is True
@pytest.mark.parametrize(
'url, request_expire_after, expected_expiration',
[
('https://img.site_1.com', None, timedelta(hours=12)),
('https://img.site_1.com', 60, 60),
('https://img.site.com/base/', None, 1),
('https://img.site.com/base/img.jpg', None, 1),
('http://site_2.com/resource_1', None, timedelta(hours=20)),
('ftp://site_2.com/resource_1/index.html', None, timedelta(hours=20)),
('http://site_2.com/resource_2/', None, timedelta(days=7)),
('http://site_2.com/static/', None, -1),
('http://site_2.com/static/img.jpg', None, -1),
('http://site_2.com', None, 1),
('http://site_2.com', 60, 60),
('https://some_other_site.com', None, 1),
('https://some_other_site.com', 60, 60),
],
)
def test_init_from_settings(url, request_expire_after, expected_expiration):
"""Test with per-session, per-request, and per-URL expiration"""
settings = CacheSettings(
expire_after=1,
urls_expire_after={
'*.site_1.com': timedelta(hours=12),
'site_2.com/resource_1': timedelta(hours=20),
'site_2.com/resource_2': timedelta(days=7),
'site_2.com/static': -1,
},
)
request = Request(method='GET', url=url)
if request_expire_after:
request.headers = {'Cache-Control': f'max-age={request_expire_after}'}
actions = CacheActions.from_request('key', request.prepare(), settings)
assert actions.expire_after == expected_expiration
@pytest.mark.parametrize(
'headers, expire_after, expected_expiration, expected_skip_read',
[
({'Cache-Control': 'max-age=60'}, 1, 60, False),
({}, 1, 1, False),
({}, 0, 0, False),
({'Cache-Control': 'max-age=60'}, 1, 60, False),
({'Cache-Control': 'max-age=0'}, 1, 0, False),
({'Cache-Control': 'no-store'}, 1, 1, True),
({'Cache-Control': 'no-cache'}, 1, 1, True),
],
)
def test_init_from_settings_and_headers(
headers, expire_after, expected_expiration, expected_skip_read
):
"""Test behavior with both cache settings and request headers."""
request = Request(method='GET', url=MOCKED_URL, headers=headers)
settings = CacheSettings(expire_after=expire_after)
actions = CacheActions.from_request('key', request, settings)
assert actions.expire_after == expected_expiration
assert actions.skip_read == expected_skip_read
def test_update_from_cached_response__new_request():
actions = CacheActions.from_request('key', BASIC_REQUEST)
actions.update_from_cached_response(None)
assert actions.send_request is True
def test_update_from_cached_response__resend_request():
actions = CacheActions.from_request('key', BASIC_REQUEST)
actions.update_from_cached_response(EXPIRED_RESPONSE)
assert actions.resend_request is True
@pytest.mark.parametrize(
'response_headers, expected_validation_headers',
[
({}, {}),
({'ETag': ETAG}, {'If-None-Match': ETAG}),
({'Last-Modified': LAST_MODIFIED}, {'If-Modified-Since': LAST_MODIFIED}),
(
{'ETag': ETAG, 'Last-Modified': LAST_MODIFIED},
{'If-None-Match': ETAG, 'If-Modified-Since': LAST_MODIFIED},
),
],
)
def test_update_from_cached_response__revalidate(response_headers, expected_validation_headers):
"""Conditional request headers should be added if the cached response is expired"""
actions = CacheActions.from_request('key', BASIC_REQUEST)
cached_response = CachedResponse(
headers=response_headers, expires=datetime.utcnow() - timedelta(1)
)
actions.update_from_cached_response(cached_response)
assert actions.send_request is bool(expected_validation_headers)
assert actions._validation_headers == expected_validation_headers
@pytest.mark.parametrize(
'request_headers, response_headers',
[
({}, {'Cache-Control': 'no-cache'}),
({}, {'Cache-Control': 'max-age=0,must-revalidate'}),
],
)
def test_update_from_cached_response__refresh(request_headers, response_headers):
"""Conditional request headers should be added if requested by response headers, even if the
response is not expired
"""
actions = CacheActions.from_request(
'key', Request(url='https://img.site.com/base/img.jpg', headers=request_headers)
)
cached_response = CachedResponse(headers={'ETag': ETAG, **response_headers}, expires=None)
actions.update_from_cached_response(cached_response)
assert actions.send_request is True
assert actions._validation_headers == {'If-None-Match': ETAG}
def test_update_from_cached_response__no_revalidation():
"""Conditional request headers should NOT be added if the cached response is not expired and
revalidation is otherwise not requested"""
actions = CacheActions.from_request('key', BASIC_REQUEST)
cached_response = CachedResponse(
headers={'ETag': ETAG, 'Last-Modified': LAST_MODIFIED}, expires=None
)
actions.update_from_cached_response(cached_response)
assert actions._validation_headers == {}
def test_update_from_cached_response__504():
settings = CacheSettings(only_if_cached=True)
actions = CacheActions.from_request('key', BASIC_REQUEST, settings=settings)
actions.update_from_cached_response(EXPIRED_RESPONSE)
assert actions.error_504 is True
def test_update_from_cached_response__stale_if_error():
settings = CacheSettings(only_if_cached=True, stale_if_error=True)
actions = CacheActions.from_request('key', BASIC_REQUEST, settings=settings)
actions.update_from_cached_response(EXPIRED_RESPONSE)
assert actions.error_504 is False and actions.resend_request is False
def test_update_from_cached_response__stale_while_revalidate():
settings = CacheSettings(only_if_cached=True, stale_while_revalidate=True)
actions = CacheActions.from_request('key', BASIC_REQUEST, settings=settings)
actions.update_from_cached_response(EXPIRED_RESPONSE)
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
max-stale
"""
request = Request(
url='https://img.site.com/base/img.jpg',
headers={'Cache-Control': f'max-stale={max_stale}'},
)
actions = CacheActions.from_request('key', request)
cached_response = CachedResponse(expires=datetime.utcnow() - timedelta(seconds=10))
assert actions.is_usable(cached_response) is usable
@pytest.mark.parametrize('min_fresh, usable', [(5, True), (15, False)])
def test_is_usable__min_fresh(min_fresh, usable):
"""For a response that expires in 10 seconds, it may be either accepted or rejected based on
min-fresh
"""
request = Request(
url='https://img.site.com/base/img.jpg',
headers={'Cache-Control': f'min-fresh={min_fresh}'},
)
actions = CacheActions.from_request('key', request)
cached_response = CachedResponse(expires=datetime.utcnow() + timedelta(seconds=10))
assert actions.is_usable(cached_response) is usable
@pytest.mark.parametrize(
'stale_if_error, error, usable',
[
(5, True, False),
(15, True, True),
(15, False, False),
],
)
def test_is_usable__stale_if_error(stale_if_error, error, usable):
"""For a response that expired 10 seconds ago, if an error occured while refreshing, it may be
either accepted or rejected based on stale-if-error
"""
request = Request(
url='https://img.site.com/base/img.jpg',
headers={'Cache-Control': f'stale-if-error={stale_if_error}'},
)
actions = CacheActions.from_request('key', request)
cached_response = CachedResponse(expires=datetime.utcnow() - timedelta(seconds=10))
assert actions.is_usable(cached_response, error=error) is usable
@pytest.mark.parametrize(
'stale_while_revalidate, usable',
[
(5, False),
(15, True),
],
)
def test_is_usable__stale_while_revalidate(stale_while_revalidate, usable):
"""For a response that expired 10 seconds ago, if an error occured while refreshing, it may be
either accepted or rejected based on stale-while-revalidate
"""
request = Request(
url='https://img.site.com/base/img.jpg',
headers={'Cache-Control': f'stale-while-revalidate={stale_while_revalidate}'},
)
actions = CacheActions.from_request('key', request)
cached_response = CachedResponse(expires=datetime.utcnow() - timedelta(seconds=10))
assert actions.is_usable(cached_response=cached_response) is usable
@pytest.mark.parametrize(
'headers, expected_expiration',
[
({}, None),
({'Cache-Control': 'no-cache'}, None), # Forces revalidation, but no effect on expiration
({'Cache-Control': 'max-age=0'}, 0),
({'Cache-Control': 'max-age=60'}, 60),
({'Cache-Control': 'public, max-age=60'}, 60),
({'Cache-Control': 'max-age=0'}, 0),
({'Cache-Control': 'immutable'}, -1),
({'Cache-Control': 'immutable, max-age=60'}, -1), # Immutable should take precedence
({'Expires': HTTPDATE_STR}, HTTPDATE_STR),
({'Expires': HTTPDATE_STR, 'Cache-Control': 'max-age=60'}, 60),
],
)
def test_update_from_response(headers, expected_expiration):
"""Test with Cache-Control response headers"""
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
actions.update_from_response(get_mock_response(headers=headers))
assert actions.expire_after == expected_expiration
assert actions.skip_write is (expected_expiration == EXPIRE_IMMEDIATELY)
def test_update_from_response__no_store():
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
actions.update_from_response(get_mock_response(headers={'Cache-Control': 'no-store'}))
assert actions.skip_write is True
def test_update_from_response__ignored():
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=False))
actions.update_from_response(get_mock_response(headers={'Cache-Control': 'max-age=5'}))
assert actions.expire_after is None
@pytest.mark.parametrize('validator_headers', [{'ETag': ETAG}, {'Last-Modified': LAST_MODIFIED}])
@pytest.mark.parametrize('cache_headers', [{'Cache-Control': 'max-age=0'}, {'Expires': '0'}])
@patch('requests_cache.expiration.datetime')
def test_update_from_response__revalidate(mock_datetime, cache_headers, validator_headers):
"""If expiration is 0 and there's a validator, the response should be cached, but with immediate
expiration
"""
actions = CacheActions.from_request('key', BASIC_REQUEST, CacheSettings(cache_control=True))
response = get_mock_response(headers={**cache_headers, **validator_headers})
actions.update_from_response(response)
assert actions.expires == mock_datetime.utcnow()
assert actions.skip_write is False
@pytest.mark.parametrize('directive', IGNORED_DIRECTIVES)
def test_ignored_headers(directive):
"""Ensure that currently unimplemented Cache-Control headers do not affect behavior"""
request = Request(
method='GET', url='https://img.site.com/base/img.jpg', headers={'Cache-Control': directive}
).prepare()
settings = CacheSettings(expire_after=1, cache_control=True)
actions = CacheActions.from_request('key', request, settings)
assert actions.expire_after == 1
assert actions.skip_read is False
assert actions.skip_write is False