Skip to content

Commit 66a8e05

Browse files
committed
Improve exception handling for the cached decorator. Fixes #444
1 parent b09952d commit 66a8e05

4 files changed

Lines changed: 147 additions & 2 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ Unreleased
4848
cache hit with the cached value. Unlike ``forced_update`` it uses the value itself
4949
to check whether the cache is stale. Can be used in combination with ``forced_update``
5050
:issue:`392`
51+
- ``@cached`` now caches an ``HTTPException`` raised by the view (i.e. through ``abort()``).
52+
This exception will now be re-raises on a cache hit. Use ``response_filter``,
53+
to keep the exception out of the cache. :issue:`444`
5154
- Fix a ``@memoize`` cache-key collision when a parameter has a falsy
5255
default (e.g. ``0``, ``""``, ``False``): calling with the default was
5356
keyed the same as passing ``None``, returning the wrong cached result.

docs/usage.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,37 @@ of the cache::
460460
def index():
461461
return render_template('index.html')
462462

463+
.. versionchanged:: 2.5.0
464+
465+
An ``HTTPException`` raised by a view for example through
466+
:func:`flask.abort` is cached like a returned response and re-raised on a
467+
cache hit, so :meth:`~flask.Flask.errorhandler` functions still run. The
468+
filter is called with the exception's response.
469+
470+
.. warning::
471+
472+
A view that aborts because of a server problem (i.e. status code ``503``)
473+
keeps returning that error until the entry expires. Use ``response_filter`` to
474+
keep such a status code out of the cache.
475+
476+
In case a view returns just a plain string (has no ``status_code``), use a default
477+
value for the response. For example::
478+
479+
def not_server_error(response):
480+
return getattr(response, "status_code", 200) < 500
481+
482+
@app.route("/article/<slug>")
483+
@cache.cached(timeout=50, response_filter=not_server_error)
484+
def article(slug):
485+
if not backend.healthy():
486+
abort(503) # raised again on every request
487+
488+
article = load(slug)
489+
if article is None:
490+
abort(404) # cached and re-raised for 50 seconds
491+
492+
return render_template("article.html", article=article)
493+
463494

464495
cache_none
465496
``````````

src/flask_caching/__init__.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from flask import request
3535
from flask import Response
3636
from flask import url_for
37+
from werkzeug.exceptions import HTTPException
3738
from werkzeug.utils import import_string
3839

3940
from .backends.base import BaseCache
@@ -369,6 +370,15 @@ def _call_fn(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
369370
return ensure_sync(fn)(*args, **kwargs)
370371
return fn(*args, **kwargs)
371372

373+
def _call_fn_or_exception(
374+
self, fn: Callable[..., Any], *args: Any, **kwargs: Any
375+
) -> Any:
376+
"""Returns an ``HTTPException`` instead of propagating it."""
377+
try:
378+
return self._call_fn(fn, *args, **kwargs)
379+
except HTTPException as e:
380+
return e
381+
372382
@property
373383
def cache(self) -> SimpleCache:
374384
"""The backend instance the proxy methods delegate to. Use this to
@@ -572,6 +582,13 @@ def get_list():
572582
:param response_hit_indication: Default False.
573583
If True, it will add to response header field 'hit_cache'
574584
if used cache.
585+
586+
.. versionchanged:: 2.5.0
587+
A ``werkzeug.exceptions.HTTPException`` raised by the decorated
588+
function, for example through Flask's ``abort()``, is now cached
589+
like a returned response and re-raised on a cache hit. Use
590+
``response_filter``, which is given the exception's response, to
591+
keep it out of the cache.
575592
"""
576593

577594
def decorator(f: Callable[P, R]) -> _CachedFunction[P, R]:
@@ -642,12 +659,17 @@ def apply_caching(response: Response) -> Response:
642659
cache=self, cache_key=cache_key, args=args, kwargs=kwargs
643660
)
644661

662+
if found and isinstance(rv, HTTPException):
663+
raise rv
664+
645665
if not found:
646-
rv = self._call_fn(f, *args, **kwargs)
666+
rv = self._call_fn_or_exception(f, *args, **kwargs)
647667
if inspect.isgenerator(rv):
648668
rv = join_generator(rv)
649669

650-
if response_filter is None or response_filter(rv):
670+
if response_filter is None or response_filter(
671+
rv.get_response() if isinstance(rv, HTTPException) else rv
672+
):
651673
cache_timeout = normalize_timeout(cached_fn.cache_timeout)
652674
if isinstance(rv, CachedResponse):
653675
cache_timeout = rv.timeout or cache_timeout
@@ -662,6 +684,9 @@ def apply_caching(response: Response) -> Response:
662684
if self.app.debug:
663685
raise
664686
logger.exception("Exception possibly due to cache backend.")
687+
688+
if isinstance(rv, HTTPException):
689+
raise rv
665690
return rv
666691

667692
def default_make_cache_key(*args: Any, **kwargs: Any) -> str:

tests/test_view.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import itertools
33
import time
44

5+
from flask import abort
56
from flask import make_response
67
from flask import request
78
from flask.views import View
@@ -767,3 +768,88 @@ def view_user(name):
767768
== in_request
768769
)
769770
assert view_user.make_cache_key(name="bob") == in_request_user
771+
772+
773+
def test_cached_view_http_exception(app, cache):
774+
calls = []
775+
776+
@app.route("/missing")
777+
@cache.cached(2)
778+
def cached_view():
779+
calls.append(1)
780+
abort(404, "no such thing")
781+
782+
tc = app.test_client()
783+
784+
first = tc.get("/missing")
785+
second = tc.get("/missing")
786+
787+
assert first.status_code == second.status_code == 404
788+
assert len(calls) == 1
789+
790+
791+
def test_cached_view_http_exception_runs_error_handler(app, cache):
792+
calls = []
793+
794+
@app.errorhandler(404)
795+
def handle_404(error):
796+
return f"handled {error.description}", 404
797+
798+
@app.route("/missing")
799+
@cache.cached(2)
800+
def cached_view():
801+
calls.append(1)
802+
abort(404, "no such thing")
803+
804+
tc = app.test_client()
805+
806+
tc.get("/missing")
807+
cached = tc.get("/missing")
808+
809+
assert cached.get_data(as_text=True) == "handled no such thing"
810+
assert len(calls) == 1
811+
812+
813+
def test_cached_view_http_exception_expires(app, cache, clock):
814+
calls = []
815+
816+
@app.route("/missing")
817+
@cache.cached(2)
818+
def cached_view():
819+
calls.append(1)
820+
abort(404)
821+
822+
tc = app.test_client()
823+
824+
tc.get("/missing")
825+
clock.advance(1)
826+
tc.get("/missing")
827+
828+
assert len(calls) == 1
829+
830+
clock.advance(2)
831+
assert tc.get("/missing").status_code == 404
832+
assert len(calls) == 2
833+
834+
835+
def test_cached_view_http_exception_response_filter_gets_response(app, cache):
836+
calls = []
837+
seen = []
838+
839+
def only_success(response):
840+
seen.append(response)
841+
return response.status_code == 200
842+
843+
@app.route("/down")
844+
@cache.cached(2, response_filter=only_success)
845+
def cached_view():
846+
calls.append(1)
847+
abort(503)
848+
849+
tc = app.test_client()
850+
851+
assert tc.get("/down").status_code == 503
852+
assert tc.get("/down").status_code == 503
853+
854+
assert [response.status_code for response in seen] == [503, 503]
855+
assert len(calls) == 2

0 commit comments

Comments
 (0)