summaryrefslogtreecommitdiff
path: root/requests_cache
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 /requests_cache
parent3aa84ee6724491858a81ce401487fc85d0ee9c9d (diff)
downloadrequests-cache-bbd984375d22dedaf33c8d0cad718cc09d072d25.tar.gz
Implement Cache-Control: stale-while-revalidate
Diffstat (limited to 'requests_cache')
-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
4 files changed, 49 insertions, 16 deletions
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