summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJordan Cook <jordan.cook@pioneer.com>2022-04-22 20:10:57 -0500
committerJordan Cook <jordan.cook@pioneer.com>2022-05-04 16:17:22 -0500
commitbbd984375d22dedaf33c8d0cad718cc09d072d25 (patch)
tree37eb6cfad188feefcd18dff05de9e591c7dff1aa
parent3aa84ee6724491858a81ce401487fc85d0ee9c9d (diff)
downloadrequests-cache-bbd984375d22dedaf33c8d0cad718cc09d072d25.tar.gz
Implement Cache-Control: stale-while-revalidate
-rw-r--r--.gitignore1
-rw-r--r--HISTORY.md30
-rw-r--r--docs/user_guide/expiration.md23
-rw-r--r--docs/user_guide/headers.md8
-rw-r--r--requests_cache/policy/actions.py44
-rw-r--r--requests_cache/policy/directives.py1
-rw-r--r--requests_cache/policy/settings.py1
-rw-r--r--requests_cache/session.py19
-rw-r--r--tests/conftest.py5
-rw-r--r--tests/unit/policy/test_actions.py116
-rw-r--r--tests/unit/test_session.py132
11 files changed, 275 insertions, 105 deletions
diff --git a/.gitignore b/.gitignore
index 24e3bed..b501189 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ example_cache/
http_cache/
venv/
.venv
+Pipfile
# JS
node_modules/
diff --git a/HISTORY.md b/HISTORY.md
index ff9c406..5bd84f4 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -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