Skip to content
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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions drf_kit/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Contributor

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:

@cache_response decorator is used to apply cache to view sets. Usually, attempts to fetch cached data is a "nice to have" to any view call in terms of performance. Therefore, any error on cache should not fail the view call. Instead, a more resilient approach is to ignore cache on errors, and proceed to usual view call.

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 on cache_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:

  • There is no cached data for the endpoint. In this case, not setting the cache hurts performance, but doesn't hurt correctness.
  • There is already cached data for the endpoint. In this case, not setting the cache doesn't hurt performance (as future GET requests will hit the cache), but it hurts correctness (a stale value will be read)

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)

Copy link
Contributor Author

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:

        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)
            else:
                raise from exc

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,
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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.
Exception is too broad and it might suppress some dumb coding errors such as AttributeError, which is easily fixed and not related to cache itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get that handling Exception is not the best practice when the code block inside try is proprietary code, but actually self.cache.get calls a cache inside user code (as cache is defined by them). Therefore, by passing the exception to cache_error_func, we are giving the user the power to deal with whatever cache-specific error it might have.

However, as you said, I do see that user might have configured cache just wrong, and both self.cache, self.cache.get can be None (leading to AttributeError) or self.cache.get might require a different number of parameters (leading to a TypeError). In these two cases, I do see benefit in handling exceptions inside drf-toolkit code. Else, I think we should let the user handle whatever error occurs (thanks for the suggestion!).

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
2 changes: 1 addition & 1 deletion pyproject.toml
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 = [
Expand Down
44 changes: 43 additions & 1 deletion test_app/tests/tests_views/tests_cached_views.py
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
Expand Down Expand Up @@ -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,
):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This patch makes the handle_cache_error_helper method instead of the handle_cache_error. For some reason mocking handle_cache_error did not work, so I added this boilerplate to make tests reliable.

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)
18 changes: 18 additions & 0 deletions test_app/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django.db.models import Count
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response

from drf_kit.cache import cache_response
from drf_kit.views import (
BulkMixin,
ModelViewSet,
Expand Down Expand Up @@ -31,12 +33,28 @@ def add_stats_to_queryset(self, queryset):
)


def handle_cache_error_helper(exception, key, request, get_cache):
pass


# TODO: make tests mock this function instead of the helper.
# This function calls a helper so the test can mock it. For some reason,
# trying to mock `handle_cache_error` directly does not work.
def handle_cache_error(exception, key, request, get_cache):
handle_cache_error_helper(exception, key, request, get_cache)


class TeacherViewSet(CachedSearchableModelViewSet):
queryset = models.Teacher.objects.all()
serializer_class = serializers.TeacherSerializer
filterset_class = filters.TeacherFilterSet
ordering_fields = ("name", "id")

@action(detail=False, methods=["get"])
@cache_response(cache_error_func=handle_cache_error)
def patronus(self, request, *args, **kwargs):
return Response(status=status.HTTP_200_OK)


class WizardViewSet(ModelViewSet):
queryset = models.Wizard.objects.all()
Expand Down
Loading