-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add fallback to cache error #60
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -38,6 +38,25 @@ class BodyCacheKeyConstructor(CacheKeyConstructor): | |
|
||
|
||
class CacheResponse(decorators.CacheResponse): | ||
def __init__(self, timeout=None, key_func=None, cache=None, cache_errors=None, cache_error_func=None): | ||
""" | ||
:param cache_error_func: Function to be called when an error occurs while trying to get the cache. | ||
parameters to this function include the exception that was raised, the key that was used to get the cache, | ||
the request that was made and whether the error occurred when fetching the cache or when setting it. | ||
|
||
This function should look like this: | ||
def cache_error_func(exception: Exception, key: str, request: Request, get_cache: bool): | ||
pass | ||
""" | ||
super().__init__( | ||
timeout, | ||
key_func, | ||
cache, | ||
cache_errors, | ||
) | ||
|
||
self.cache_error_func = cache_error_func | ||
|
||
def process_cache_response( | ||
self, | ||
view_instance, | ||
|
@@ -62,7 +81,7 @@ def process_cache_response( | |
response_dict = None | ||
else: | ||
valid_cache_control = False | ||
response_dict = self.cache.get(key) | ||
response_dict = self._get_cached_result(key, request) | ||
|
||
if not response_dict: | ||
response = view_method(view_instance, request, *args, **kwargs) | ||
|
@@ -81,7 +100,7 @@ def process_cache_response( | |
response.headers.copy(), | ||
) | ||
|
||
self.cache.set(key, response_dict, self.timeout) | ||
self._set_cached_result(key, request, response_dict) | ||
cache_hit = False | ||
else: | ||
try: | ||
|
@@ -100,5 +119,21 @@ def process_cache_response( | |
|
||
return response | ||
|
||
def _get_cached_result(self, key, request) -> dict | None: | ||
try: | ||
return self.cache.get(key) | ||
except Exception as exc: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should handle specific errors here, and for this we must test extensively what can go wrong. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get that handling However, as you said, I do see that user might have configured cache just wrong, and both Besides, considering we might not want to hide errors from someone who should know about it, we can make this "ignore error" behavior a default disabled? That way, someone has consciously choose to ignore errors (or handle them in a custom manner). Wdyt? |
||
if self.cache_error_func: | ||
self.cache_error_func(exception=exc, key=key, request=request, get_cache=True) | ||
|
||
return None | ||
|
||
def _set_cached_result(self, key, request, response_dict): | ||
try: | ||
self.cache.set(key, response_dict, self.timeout) | ||
except Exception as exc: | ||
if self.cache_error_func: | ||
self.cache_error_func(exception=exc, key=key, request=request, get_cache=False) | ||
|
||
|
||
cache_response = CacheResponse |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
[tool.poetry] | ||
name = "drf-kit" | ||
version = "1.42.4" | ||
version = "1.43.0" | ||
description = "DRF Toolkit" | ||
authors = ["Nilo Saude <[email protected]>"] | ||
packages = [ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,8 @@ | ||
from unittest.mock import patch | ||
from unittest.mock import ANY, patch | ||
|
||
from django.core.cache import cache | ||
from rest_framework import status | ||
from rest_framework.response import Response | ||
|
||
from drf_kit.cache import CacheResponse | ||
from drf_kit.tests import BaseApiTest | ||
|
@@ -105,3 +106,44 @@ def test_cache_control_invalid_directive(self): | |
self.assertEqual(status.HTTP_200_OK, response_json_miss.status_code) | ||
self.assertEqual("HIT", response_json_miss["X-Cache"]) | ||
self.assertEqual(None, response_json_miss.get("Cache-Control")) | ||
|
||
def test_cache_skip_on_cache_get_and_call_view_action(self): | ||
url = self.url | ||
|
||
with ( | ||
patch("django.core.cache.backends.locmem.LocMemCache.get", side_effect=Exception), | ||
patch("drf_kit.views.viewsets.MultiSerializerMixin.list", return_value=Response()) as list_method, | ||
): | ||
self.client.get(url) | ||
|
||
list_method.assert_called_once() | ||
|
||
def test_cache_skip_on_cache_set(self): | ||
url = self.url | ||
|
||
with ( | ||
patch("django.core.cache.backends.locmem.LocMemCache.set", side_effect=Exception), | ||
): | ||
self.client.get(url) # No exception should be raised from this call | ||
|
||
def test_cache_custom_handler_on_cache_get_error(self): | ||
url = f"{self.url}/patronus" | ||
|
||
with ( | ||
patch("django.core.cache.backends.locmem.LocMemCache.get", side_effect=Exception), | ||
patch("test_app.views.handle_cache_error_helper") as error_handler, | ||
): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This patch makes the |
||
self.client.get(url) | ||
|
||
error_handler.assert_called_once_with(ANY, ANY, ANY, True) | ||
|
||
def test_cache_custom_handler_on_cache_set_error(self): | ||
url = f"{self.url}/patronus" | ||
|
||
with ( | ||
patch("django.core.cache.backends.locmem.LocMemCache.set", side_effect=Exception), | ||
patch("test_app.views.handle_cache_error_helper") as error_handler, | ||
): | ||
self.client.get(url) | ||
|
||
error_handler.assert_called_once_with(ANY, ANY, ANY, False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking about the general DX of this solution here. First, I only partially agree with what was stated in the issue description:
Attempts to fetch cached data are, indeed, usually "nice to have", but emphasis on usually: for an endpoint that is heavily accessed and has a cache hit ratio well over 90%, it is not a nice to have, it is part of the infrastructure. If the cache fails and we send all requests upstream as if there was no cache, we're likely to cause a self-inflicted DDoS since the load will increase drastically in a moment's notice. If this unexpected load top up database capacity, we might have a bigger (broader) availability problem than just the errors on the cached endpoint.
In that sense, while we could have a default behavior of calling the view, perhaps this should be configurable on specific endpoints in which this is not a real option (especially considering drf_kit is not Nilo-specific). Since we take a
cache_error_func
, this is actually already possible (all that is needed is re-raising the exception oncache_error_func
), so perhaps there is nothing needed to address the "GET" case.As for the "SET" case, it is a bit more complicated: besides the same considerations done for the "GET" case, we need to consider that there are two possible scenarios:
So I don't think ignoring cache fails for updates should be the default behavior since it introduces data inconsistencies (but I'm okay with apps that use drf kit being able to opt-in for this behavior)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point! I was thinking over of whether cache was indeed a "nice to have", and you make it clear that it might be, but might not be as well.
Just to make clear, your suggestion is to make it disabled default , and the user has to opt-in to have their error ignored? If it is, sounds good to me. The set method can be something like: