diff options
author | Jordan Cook <jordan.cook@pioneer.com> | 2022-04-22 20:10:57 -0500 |
---|---|---|
committer | Jordan Cook <jordan.cook@pioneer.com> | 2022-05-04 16:17:22 -0500 |
commit | bbd984375d22dedaf33c8d0cad718cc09d072d25 (patch) | |
tree | 37eb6cfad188feefcd18dff05de9e591c7dff1aa | |
parent | 3aa84ee6724491858a81ce401487fc85d0ee9c9d (diff) | |
download | requests-cache-bbd984375d22dedaf33c8d0cad718cc09d072d25.tar.gz |
Implement Cache-Control: stale-while-revalidate
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | HISTORY.md | 30 | ||||
-rw-r--r-- | docs/user_guide/expiration.md | 23 | ||||
-rw-r--r-- | docs/user_guide/headers.md | 8 | ||||
-rw-r--r-- | requests_cache/policy/actions.py | 44 | ||||
-rw-r--r-- | requests_cache/policy/directives.py | 1 | ||||
-rw-r--r-- | requests_cache/policy/settings.py | 1 | ||||
-rw-r--r-- | requests_cache/session.py | 19 | ||||
-rw-r--r-- | tests/conftest.py | 5 | ||||
-rw-r--r-- | tests/unit/policy/test_actions.py | 116 | ||||
-rw-r--r-- | tests/unit/test_session.py | 132 |
11 files changed, 275 insertions, 105 deletions
@@ -11,6 +11,7 @@ example_cache/ http_cache/ venv/ .venv +Pipfile # JS node_modules/ @@ -3,26 +3,30 @@ ## 1.0.0 (Unreleased) [See all unreleased issues and PRs](https://github.com/reclosedev/requests-cache/milestone/5?closed=1) -**Expiration & Headers:** +**Expiration & headers:** * Add support for `Cache-Control: min-fresh` * Add support for `Cache-Control: max-stale` -* Add support for `Cache-Control: stale-if-error` * Add support for `Cache-Control: only-if-cached` +* Add support for `Cache-Control: stale-if-error` +* Add support for `Cache-Control: stale-while-error` * Revalidate for `Cache-Control: no-cache` request or response header * Revalidate for `Cache-Control: max-age=0, must-revalidate` response headers * Add an attribute `CachedResponse.revalidated` to indicate if a cached response was revalidated for the current request -**Settings:** +**Session settings:** * All settings that affect cache behavior can now be accessed and modified via `CachedSession.settings` +* Add `always_revalidate` session setting to always revalidate before using a cached response (if a validator) is available. +* Add `only_if_cached` settion setting to return only cached results without sending real requests +* Add `stale_while_revalidate` session setting to return a stale response initially, while a non-blocking request is sent to refresh the response +* Make behavior for `stale_if_error` partially consistent with `Cache-Control: stale-if-error`: Add support for time values (int, timedelta, etc.) in addition to `True/False` + +**Request settings:** * Add `only_if_cached` option to `CachedSession.request()` and `send()` to return only cached results without sending real requests * Add `refresh` option to `CachedSession.request()` and `send()` to revalidate with the server before using a cached response * Add `force_refresh` option to `CachedSession.request()` and `send()` to awlays make and cache a new request regardless of existing cache contents * Make behavior for `expire_after=0` consistent with `Cache-Control: max-age=0`: if the response has a validator, save it to the cache but revalidate on use. * The constant `requests_cache.DO_NOT_CACHE` may be used to completely disable caching for a request -* Make behavior for `stale_if_error` partially consistent with `Cache-Control: stale-if-error`: Add support for time values (int, timedelta, etc.) in addition to `True/False` -* Add `always_revalidate` session setting to always revalidate before using a cached response (if a - validator) is available. **Backends:** * SQLite: @@ -42,7 +46,7 @@ * The default file format has been changed from pickle to JSON * SQLite, Redis, MongoDB, and GridFS: Close open database connections when `CachedSession` is used as a contextmanager, or if `CachedSession.close()` is called -**Request Matching & Filtering:** +**Request matching & filtering:** * Add serializer name to cache keys to avoid errors due to switching serializers * Always skip both cache read and write for requests excluded by `allowable_methods` (previously only skipped write) * Ignore and redact common authentication headers and request parameters by default. This provides some default recommended values for `ignored_parameters`, to avoid accidentally storing common credentials (e.g., OAuth tokens) in the cache. This will have no effect if you are already setting `ignored_parameters`. @@ -63,7 +67,7 @@ **Breaking changes:** Some relatively minor breaking changes have been made that are not expected to affect most users. -If you encounter a problem not listed here after updating, please file a bug report! +If you encounter a problem not listed here after updating to 1.0, please file a bug report! The following undocumented behaviors have been removed: * The arguments `match_headers` and `ignored_parameters` must be passed to `CachedSession`. Previously, these could also be passed to a `BaseCache` instance. @@ -79,6 +83,10 @@ The following is relevant for users who have made custom backends that extend bu Internal utility module changes: * The `cache_control` module (added in `0.7`) has been split up into multiple modules in a new `policy` subpackage +### 0.9.5 (Unreleased) +* Fix usage of memory backend with `install_cache()` +* Add compatibility with cattrs 22.1 + ### 0.9.4 (2022-04-22) * Fix forwarding connection parameters passed to `RedisCache` for redis-py 4.2 and python <=3.8 * Fix forwarding connection parameters passed to `MongoCache` for pymongo 4.1 and python <=3.8 @@ -109,7 +117,7 @@ Internal utility module changes: ## 0.9.0 (2022-01-01) [See all issues and PRs for 0.9](https://github.com/reclosedev/requests-cache/milestone/4?closed=1) -**Expiration & Headers:** +**Expiration & headers:** * Use `Cache-Control` **request** headers by default * Add support for `Cache-Control: immutable` * Add support for immediate expiration + revalidation with `Cache-Control: max-age=0` and `Expires: 0` @@ -145,7 +153,7 @@ Internal utility module changes: ## 0.8.0 (2021-09-07) [See all issues and PRs for 0.8](https://github.com/reclosedev/requests-cache/milestone/3?closed=1) -**Expiration & Headers:** +**Expiration & headers:** * Add support for conditional requests and cache validation using: * `ETag` + `If-None-Match` headers * `Last-Modified` + `If-Modified-Since` headers @@ -246,7 +254,7 @@ The following changes are meant to make certain behaviors more obvious for new u * Redis: `redis.Redis` * MongoDB and GridFS: `pymongo.MongoClient` -**Expiration & Headers:** +**Expiration & headers:** * Add optional support for the following **request** headers: * `Cache-Control: max-age` * `Cache-Control: no-cache` diff --git a/docs/user_guide/expiration.md b/docs/user_guide/expiration.md index ab79b24..b0628fb 100644 --- a/docs/user_guide/expiration.md +++ b/docs/user_guide/expiration.md @@ -120,6 +120,29 @@ In addition to HTTP error codes, `stale_if_error` also applies to python excepti [Errors and Exceptions](https://2.python-requests.org/en/master/user/quickstart/#errors-and-exceptions) for more details on request errors in general. +(stale-while-revalidate)= +## Asynchronous Revalidation +You can use the `stale_while_revalidate` option to improve performance when refreshing responses. +This will cause an expired cached response to be returned initially, while a non-blocking request is +sent to refresh the response for the next time it's requested. + +```{note} +While the corresponding response header `Cache-Control: stale-while-revalidate` only applies to +{ref}`conditional-requests`, requests-cache extends this behavior to other refresh requests as well +(even if a validator is not available). +``` + +You may either set this to `True` to do this regardless of the cached response's age: +```python +session = CachedSession(stale_while_revalidate=True) +``` + +Or specify a maximum staleness value you are willing to accept: +```python +# Use a cached response while revalidating, if it expired 5 minutes ago or less +session = CachedSession(stale_while_revalidate=timedelta(minutes=5)) +``` + ## Removing Expired Responses ### Manual Removal diff --git a/docs/user_guide/headers.md b/docs/user_guide/headers.md index 0ce1ee4..3b3e8e9 100644 --- a/docs/user_guide/headers.md +++ b/docs/user_guide/headers.md @@ -26,6 +26,10 @@ requests-cache repo: True ``` +```{note} +Also see {ref}`stale-while-revalidate` for a variation of this behavior. +``` + ## Cache-Control `Cache-Control` **request** headers will always be used if present. This is mainly useful if you are adding requests-cache to an existing application or library that already sends requests with cache @@ -62,11 +66,13 @@ The following headers are currently supported: - `If-Modified-Since`: Automatically added for revalidation, if `Last-Modified` is available **Response headers:** +- `Cache-Control: immutable`: Cache the response with no expiration - `Cache-Control: max-age`: Used as the expiration time in seconds - `Cache-Control: must-revalidate`: When used in combination with `max-age=0`, revalidate immediately. - `Cache-Control: no-cache`: Revalidate with the server before using a cached response - `Cache-Control: no-store` Skip writing to the cache -- `Cache-Control: immutable`: Cache the response with no expiration +- `Cache-Control: stale-if-error`: Same behavior as request header +- `Cache-Control: stale-while-revalidate`: If expired by less than this many seconds, return the stale response immediately and send an asynchronous revalidation request - `Expires`: Used as an absolute expiration datetime - `ETag`: Validator used for conditional requests - `Last-Modified`: Validator used for conditional requests diff --git a/requests_cache/policy/actions.py b/requests_cache/policy/actions.py index cdacefd..5d02b04 100644 --- a/requests_cache/policy/actions.py +++ b/requests_cache/policy/actions.py @@ -42,16 +42,19 @@ class CacheActions(RichMixin): expire_after: User or header-provided expiration value send_request: Send a new request resend_request: Send a new request to refresh a stale cache item + resend_async: Return a stale cache item, and send a non-blocking request to refresh it skip_read: Skip reading from the cache skip_write: Skip writing to the cache """ # Outputs + # TODO: Besides skip read/write, will there always be only one action? Should these be an enum instead? cache_key: str = field(default=None, repr=False) error_504: bool = field(default=False) expire_after: ExpirationTime = field(default=None) - resend_request: bool = field(default=False) send_request: bool = field(default=False) + resend_request: bool = field(default=False) + resend_async: bool = field(default=False) skip_read: bool = field(default=False) skip_write: bool = field(default=False) @@ -63,6 +66,7 @@ class CacheActions(RichMixin): _only_if_cached: bool = field(default=False, repr=False) _refresh: bool = field(default=False, 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) @classmethod @@ -82,6 +86,9 @@ class CacheActions(RichMixin): only_if_cached = settings.only_if_cached or directives.only_if_cached refresh = directives.max_age == EXPIRE_IMMEDIATELY or directives.must_revalidate stale_if_error = settings.stale_if_error or directives.stale_if_error + stale_while_revalidate = ( + settings.stale_while_revalidate or directives.stale_while_revalidate + ) # Check expiration values in order of precedence expire_after = coalesce( @@ -107,6 +114,7 @@ class CacheActions(RichMixin): 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, ) @@ -121,18 +129,27 @@ class CacheActions(RichMixin): def is_usable(self, cached_response: 'CachedResponse', error: bool = False): """Determine whether a given cached response is "fresh enough" to satisfy the request, - based on min-fresh, max-stale, or stale-if-error (if an error has occured). + based on: + + * min-fresh + * max-stale + * stale-if-error (if an error has occured) + * stale-while-revalidate """ if cached_response is None: return False - elif cached_response.expires is None: - return True - # Handle additional types supported for stale_if_error - elif error and self._stale_if_error is True: + elif ( + cached_response.expires is None + or (cached_response.is_expired and self._stale_while_revalidate is True) + or (error and self._stale_if_error is True) + ): return True + # Handle stale_if_error as a time value elif error and self._stale_if_error: - offset_seconds = get_expiration_seconds(self._stale_if_error) - offset = timedelta(seconds=offset_seconds) + offset = timedelta(seconds=get_expiration_seconds(self._stale_if_error)) + # Handle stale_while_revalidate as a time value + elif cached_response.is_expired and self._stale_while_revalidate: + offset = timedelta(seconds=get_expiration_seconds(self._stale_while_revalidate)) # Handle min-fresh and max-stale else: offset = self._directives.get_expire_offset() @@ -145,18 +162,21 @@ class CacheActions(RichMixin): Used after fetching a cached response, but before potentially sending a new request. """ - valid_response = self.is_usable(cached_response) - valid_if_error = self.is_usable(cached_response, error=True) + usable_response = self.is_usable(cached_response) + usable_if_error = self.is_usable(cached_response, error=True) # Can't satisfy the request - if not valid_response and self._only_if_cached and not valid_if_error: + if not usable_response and self._only_if_cached and not usable_if_error: self.error_504 = True # Send the request for the first time elif cached_response is None: self.send_request = True # Resend the request, unless settings permit a stale response - elif not valid_response and not (self._only_if_cached and valid_if_error): + elif not usable_response and not (self._only_if_cached and usable_if_error): self.resend_request = True + # Resend the request in the background; meanwhile return stale response + elif cached_response.is_expired and usable_response and self._stale_while_revalidate: + self.resend_async = True if cached_response is not None: self._update_validation_headers(cached_response) diff --git a/requests_cache/policy/directives.py b/requests_cache/policy/directives.py index 3236fe4..d6008a0 100644 --- a/requests_cache/policy/directives.py +++ b/requests_cache/policy/directives.py @@ -25,6 +25,7 @@ class CacheDirectives(RichMixin): no_store: bool = field(default=False) only_if_cached: bool = field(default=False) stale_if_error: int = field(default=None, converter=try_int) + stale_while_revalidate: int = field(default=None, converter=try_int) etag: str = field(default=None) last_modified: str = field(default=None) diff --git a/requests_cache/policy/settings.py b/requests_cache/policy/settings.py index 2f9b9fe..0070ee6 100644 --- a/requests_cache/policy/settings.py +++ b/requests_cache/policy/settings.py @@ -35,6 +35,7 @@ class CacheSettings(RichMixin): match_headers: Union[Iterable[str], bool] = field(default=False) only_if_cached: bool = field(default=False) stale_if_error: Union[bool, ExpirationTime] = field(default=False) + stale_while_revalidate: Union[bool, ExpirationTime] = field(default=False) urls_expire_after: Dict[str, ExpirationTime] = field(factory=dict) @classmethod diff --git a/requests_cache/session.py b/requests_cache/session.py index 7be3f94..f6a19dd 100644 --- a/requests_cache/session.py +++ b/requests_cache/session.py @@ -1,7 +1,7 @@ """Main classes to add caching features to :py:class:`requests.Session`""" from contextlib import contextmanager, nullcontext from logging import getLogger -from threading import RLock +from threading import RLock, Thread from typing import TYPE_CHECKING, Iterable, MutableMapping, Optional, Union from requests import PreparedRequest @@ -197,10 +197,13 @@ class CacheMixin(MIXIN_BASE): # Handle missing and expired responses based on settings and headers if actions.error_504: response: AnyResponse = get_504_response(request) - elif actions.send_request: - response = self._send_and_cache(request, actions, cached_response, **kwargs) + elif actions.resend_async: + self._resend_async(request, actions, cached_response, **kwargs) + response = cached_response # type: ignore elif actions.resend_request: response = self._resend(request, actions, cached_response, **kwargs) # type: ignore + elif actions.send_request: + response = self._send_and_cache(request, actions, cached_response, **kwargs) else: response = cached_response # type: ignore # Guaranteed to be non-None by this point @@ -256,6 +259,12 @@ class CacheMixin(MIXIN_BASE): except Exception: return self._handle_error(cached_response, actions) + def _resend_async(self, *args, **kwargs): + """Send a non-blocking request to refresh a cached response""" + logger.debug('Using stale response while revalidating') + thread = Thread(target=self._send_and_cache, args=args, kwargs=kwargs) + thread.start() + def _handle_error(self, cached_response: CachedResponse, actions: CacheActions) -> AnyResponse: """Handle a request error based on settings: * Default behavior: re-raise the error @@ -338,8 +347,10 @@ class CachedSession(CacheMixin, OriginalSession): a list of specific headers to match ignored_parameters: Request paramters, headers, and/or JSON body params to exclude from both request matching and cached request data - stale_if_error: Return stale cache data if a new request raises an exception. Optionally + stale_if_error: Return a stale response if a new request raises an exception. Optionally accepts a time value representing maximum staleness to accept. + stale_while_revalidate: Return a stale response initially, while a non-blocking request is + sent to refresh the response for the next time it's requested filter_fn: Response filtering function that indicates whether or not a given response should be cached. See :ref:`custom-filtering` for details. key_fn: Request matching function for generating custom cache keys. See diff --git a/tests/conftest.py b/tests/conftest.py index b242946..aa17c0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ from uuid import uuid4 import pytest import requests +from requests import Request from requests_mock import ANY as ANY_METHOD from requests_mock import Adapter from rich.logging import RichHandler @@ -61,7 +62,7 @@ HTTPBIN_FORMATS = [ ] HTTPDATE_STR = 'Fri, 16 APR 2021 21:13:00 GMT' HTTPDATE_DATETIME = datetime(2021, 4, 16, 21, 13) -EXPIRED_DT = datetime.now() - timedelta(1) +EXPIRED_DT = datetime.utcnow() - timedelta(1) ETAG = '"644b5b0155e6404a9cc4bd9d8b1ae730"' LAST_MODIFIED = 'Thu, 05 Jul 2012 15:31:30 GMT' @@ -221,7 +222,7 @@ def get_mock_response( url=url, status_code=status_code, headers=headers, - request=MagicMock(method=method, url=url, headers=request_headers), + request=Request(method=method, url=url, headers=request_headers), ) diff --git a/tests/unit/policy/test_actions.py b/tests/unit/policy/test_actions.py index 5c63f31..317ecc8 100644 --- a/tests/unit/policy/test_actions.py +++ b/tests/unit/policy/test_actions.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from requests import PreparedRequest, Request @@ -16,6 +16,8 @@ IGNORED_DIRECTIVES = [ '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( @@ -142,6 +144,19 @@ def test_init_from_settings_and_headers( 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', [ @@ -154,16 +169,15 @@ def test_init_from_settings_and_headers( ), ], ) -def test_update_from_cached_response(response_headers, expected_validation_headers): +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', MagicMock(url='https://img.site.com/base/img.jpg', headers={}) - ) + actions = CacheActions.from_request('key', BASIC_REQUEST) cached_response = CachedResponse( - headers=response_headers, expires=datetime.now() - timedelta(1) + 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 @@ -174,24 +188,24 @@ def test_update_from_cached_response(response_headers, expected_validation_heade ({}, {'Cache-Control': 'max-age=0,must-revalidate'}), ], ) -def test_update_from_cached_response__revalidate_headers(request_headers, response_headers): - """Conditional request headers should be added if requested by headers (even if the response - is not expired)""" +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', MagicMock(url='https://img.site.com/base/img.jpg', headers=request_headers) + '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__ignored(): +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', MagicMock(url='https://img.site.com/base/img.jpg', headers={}) - ) + actions = CacheActions.from_request('key', BASIC_REQUEST) cached_response = CachedResponse( headers={'ETag': ETAG, 'Last-Modified': LAST_MODIFIED}, expires=None ) @@ -200,6 +214,27 @@ def test_update_from_cached_response__ignored(): 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('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 @@ -210,9 +245,7 @@ def test_is_usable__max_stale(max_stale, usable): headers={'Cache-Control': f'max-stale={max_stale}'}, ) actions = CacheActions.from_request('key', request) - cached_response = CachedResponse( - expires=datetime.utcnow() - timedelta(seconds=10), - ) + cached_response = CachedResponse(expires=datetime.utcnow() - timedelta(seconds=10)) assert actions.is_usable(cached_response) is usable @@ -226,9 +259,7 @@ def test_is_usable__min_fresh(min_fresh, usable): headers={'Cache-Control': f'min-fresh={min_fresh}'}, ) actions = CacheActions.from_request('key', request) - cached_response = CachedResponse( - expires=datetime.utcnow() + timedelta(seconds=10), - ) + cached_response = CachedResponse(expires=datetime.utcnow() + timedelta(seconds=10)) assert actions.is_usable(cached_response) is usable @@ -249,13 +280,31 @@ def test_is_usable__stale_if_error(stale_if_error, error, usable): 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), - ) + 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), @@ -272,10 +321,7 @@ def test_is_usable__stale_if_error(stale_if_error, error, usable): ) def test_update_from_response(headers, expected_expiration): """Test with Cache-Control response headers""" - url = 'https://img.site.com/base/img.jpg' - actions = CacheActions.from_request( - 'key', MagicMock(url=url), CacheSettings(cache_control=True) - ) + 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 @@ -283,19 +329,13 @@ def test_update_from_response(headers, expected_expiration): def test_update_from_response__no_store(): - url = 'https://img.site.com/base/img.jpg' - actions = CacheActions.from_request( - 'key', MagicMock(url=url), CacheSettings(cache_control=True) - ) + 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(): - url = 'https://img.site.com/base/img.jpg' - actions = CacheActions.from_request( - 'key', MagicMock(url=url), CacheSettings(cache_control=False) - ) + 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 @@ -307,10 +347,7 @@ def test_update_from_response__revalidate(mock_datetime, cache_headers, validato """If expiration is 0 and there's a validator, the response should be cached, but with immediate expiration """ - url = 'https://img.site.com/base/img.jpg' - actions = CacheActions.from_request( - 'key', MagicMock(url=url), CacheSettings(cache_control=True) - ) + 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) @@ -324,7 +361,6 @@ def test_ignored_headers(directive): 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) diff --git a/tests/unit/test_session.py b/tests/unit/test_session.py index 7d22631..3a927af 100644 --- a/tests/unit/test_session.py +++ b/tests/unit/test_session.py @@ -1,11 +1,11 @@ """CachedSession tests that use mocked responses only""" import json -import time from collections import UserDict, defaultdict from datetime import datetime, timedelta from logging import getLogger from pathlib import Path from pickle import PickleError +from time import sleep, time from unittest.mock import patch from urllib.parse import urlencode @@ -343,7 +343,7 @@ def test_expired_request_error(mock_session): mock_session.settings.stale_if_error = False mock_session.settings.expire_after = 1 mock_session.get(MOCKED_URL) - time.sleep(1) + sleep(1) with patch.object(mock_session.cache, 'save_response', side_effect=ValueError): with pytest.raises(ValueError): @@ -357,7 +357,7 @@ def test_stale_if_error__exception(mock_session): assert mock_session.get(MOCKED_URL).from_cache is False assert mock_session.get(MOCKED_URL).from_cache is True - time.sleep(1) + sleep(1) with patch.object(mock_session.cache, 'save_response', side_effect=RequestException): response = mock_session.get(MOCKED_URL) assert response.from_cache is True and response.is_expired is True @@ -371,7 +371,7 @@ def test_stale_if_error__error_code(mock_session): assert mock_session.get(MOCKED_URL_404).from_cache is False - time.sleep(1) + sleep(1) response = mock_session.get(MOCKED_URL_404) assert response.from_cache is True and response.is_expired is True @@ -471,6 +471,26 @@ def test_allowable_methods(mock_session): assert mock_session.delete(MOCKED_URL).from_cache is False +def test_always_revalidate(mock_session): + """The session always_revalidate option should send a conditional request, if possible""" + mock_session.settings.expire_after = 60 + response_1 = mock_session.get(MOCKED_URL_ETAG) + response_2 = mock_session.get(MOCKED_URL_ETAG) + mock_session.mock_adapter.register_uri('GET', MOCKED_URL_ETAG, status_code=304) + + mock_session.settings.always_revalidate = True + response_3 = mock_session.get(MOCKED_URL_ETAG) + response_4 = mock_session.get(MOCKED_URL_ETAG) + + assert response_1.from_cache is False + assert response_2.from_cache is True + assert response_3.from_cache is True and response_3.revalidated is True + assert response_4.from_cache is True and response_4.revalidated is True + + # Expect expiration to get reset after revalidation + assert response_2.expires < response_4.expires + + def test_default_ignored_parameters(mock_session): """Common auth params and headers (for OAuth2, etc.) should be ignored by default""" mock_session.get( @@ -590,7 +610,7 @@ def test_304_not_modified( ): url = f'{MOCKED_URL}/endpoint_2' if cache_expired: - mock_session.settings.expire_after = datetime.now() - timedelta(1) + mock_session.settings.expire_after = datetime.utcnow() - timedelta(1) if cache_hit: mock_session.mock_adapter.register_uri('GET', url, status_code=200) mock_session.get(url) @@ -623,7 +643,7 @@ def test_remove_expired_responses(mock_normalize_url, mock_session): mock_session.settings.expire_after = 1 mock_session.get(MOCKED_URL) mock_session.get(MOCKED_URL_JSON) - time.sleep(1) + sleep(1) mock_session.get(unexpired_url) # At this point we should have 1 unexpired response and 2 expired responses @@ -634,7 +654,7 @@ def test_remove_expired_responses(mock_normalize_url, mock_session): assert cached_response.url == unexpired_url # Now the last response should be expired as well - time.sleep(1) + sleep(1) BaseCache.remove_expired_responses(mock_session.cache) assert len(mock_session.cache.responses) == 0 @@ -701,15 +721,14 @@ def test_remove_expired_responses__per_request(mock_session): assert len(mock_session.cache.responses) == 3 # One should be expired after 2s, and another should be expired after 4s - time.sleep(2) + sleep(2) mock_session.remove_expired_responses() assert len(mock_session.cache.responses) == 2 - time.sleep(2) + sleep(2) mock_session.remove_expired_responses() assert len(mock_session.cache.responses) == 1 -# @patch_normalize_url def test_remove_expired_responses__older_than(mock_session): # Cache 4 responses with different creation times response_0 = CachedResponse(request=CachedRequest(method='GET', url='https://test.com/test_0')) @@ -734,11 +753,74 @@ def test_remove_expired_responses__older_than(mock_session): assert len(mock_session.cache.responses) == 1 # Remove the last response after it's 1 second old - time.sleep(1) + sleep(1) mock_session.remove_expired_responses(older_than=timedelta(seconds=1)) assert len(mock_session.cache.responses) == 0 +def test_stale_while_revalidate(mock_session): + # Start with expired responses + mocked_url_2 = f'{MOCKED_URL_ETAG}?k=v' + mock_session.settings.stale_while_revalidate = True + mock_session.get(MOCKED_URL_ETAG, expire_after=timedelta(seconds=-2)) + mock_session.get(mocked_url_2, expire_after=timedelta(seconds=-2)) + assert mock_session.cache.has_url(MOCKED_URL_ETAG) + + # First, let's just make sure the correct method is called + mock_session.mock_adapter.register_uri('GET', MOCKED_URL_ETAG, status_code=304) + with patch.object(CachedSession, '_resend_async') as mock_send: + response = mock_session.get(MOCKED_URL_ETAG) + mock_send.assert_called_once() + + def slow_request(*args, **kwargs): + sleep(0.1) + return mock_session._send_and_cache(*args, **kwargs) + + # Next, test that the revalidation request is non-blocking + start = time() + with patch.object(CachedSession, '_send_and_cache', side_effect=slow_request) as mock_send: + response = mock_session.get(mocked_url_2, expire_after=60) + assert response.from_cache is True and response.is_expired is True + assert time() - start < 0.1 + sleep(0.1) + mock_send.assert_called() + + # Finally, check that the cached response has been refreshed + sleep(0.2) # Background thread may be a bit slow on CI runner + response = mock_session.get(mocked_url_2) + assert response.from_cache is True and response.is_expired is False + + +def test_stale_while_revalidate__time(mock_session): + """stale_while_revalidate should also accept a time value (max acceptable staleness)""" + mocked_url_2 = f'{MOCKED_URL_ETAG}?k=v' + mock_session.settings.stale_while_revalidate = timedelta(seconds=3) + mock_session.get(MOCKED_URL_ETAG, expire_after=timedelta(seconds=-2)) + response = mock_session.get(mocked_url_2, expire_after=timedelta(seconds=-4)) + + # stale_while_revalidate should apply to this response (expired 2 seconds ago) + response = mock_session.get(MOCKED_URL_ETAG) + assert response.from_cache is True and response.is_expired is True + + # but not this response (expired 4 seconds ago) + response = mock_session.get(mocked_url_2) + assert response.from_cache is False and response.is_expired is False + + +def test_stale_while_revalidate__refresh(mock_session): + """stale_while_revalidate should also apply to normal refresh requests""" + mock_session.settings.stale_while_revalidate = True + mock_session.get(MOCKED_URL, expire_after=1) + sleep(1) # An expired response without a validator won't be cached, so need to sleep + + response = mock_session.get(MOCKED_URL) + assert response.from_cache is True and response.is_expired is True + + sleep(0.2) + response = mock_session.get(MOCKED_URL) + assert response.from_cache is True and response.is_expired is False + + # Additional request() and send() options # ----------------------------------------------------- @@ -750,7 +832,7 @@ def test_request_expire_after__enable_expiration(mock_session): assert response.from_cache is False assert mock_session.get(MOCKED_URL).from_cache is True - time.sleep(1) + sleep(1) response = mock_session.get(MOCKED_URL) assert response.from_cache is False @@ -772,7 +854,7 @@ def test_request_expire_after__prepared_request(mock_session): assert response.from_cache is False assert mock_session.send(request).from_cache is True - time.sleep(1) + sleep(1) response = mock_session.get(MOCKED_URL) assert response.from_cache is False @@ -796,7 +878,7 @@ def test_request_only_if_cached__uncached(mock_session): def test_request_only_if_cached__expired(mock_session): """By default, only_if_cached will not return an expired response""" mock_session.get(MOCKED_URL, expire_after=1) - time.sleep(1) + sleep(1) response = mock_session.get(MOCKED_URL, only_if_cached=True) assert response.status_code == 504 @@ -805,7 +887,7 @@ def test_request_only_if_cached__expired(mock_session): def test_request_only_if_cached__stale_if_error__expired(mock_session): """only_if_cached *will* return an expired response if stale_if_error is also set""" mock_session.get(MOCKED_URL, expire_after=1) - time.sleep(1) + sleep(1) mock_session.settings.stale_if_error = True response = mock_session.get(MOCKED_URL, only_if_cached=True) @@ -841,26 +923,6 @@ def test_request_refresh(mock_session): assert response_2.expires < response_4.expires -def test_request_always_revalidate(mock_session): - """The session always_revalidate option should send a conditional request, if possible""" - mock_session.settings.expire_after = 60 - response_1 = mock_session.get(MOCKED_URL_ETAG) - response_2 = mock_session.get(MOCKED_URL_ETAG) - mock_session.mock_adapter.register_uri('GET', MOCKED_URL_ETAG, status_code=304) - - mock_session.settings.always_revalidate = True - response_3 = mock_session.get(MOCKED_URL_ETAG) - response_4 = mock_session.get(MOCKED_URL_ETAG) - - assert response_1.from_cache is False - assert response_2.from_cache is True - assert response_3.from_cache is True and response_3.revalidated is True - assert response_4.from_cache is True and response_4.revalidated is True - - # Expect expiration to get reset after revalidation - assert response_2.expires < response_4.expires - - def test_request_refresh__no_validator(mock_session): """The refresh option should result in a new (unconditional) request if the cached response has no validator |