Flask @cached decorator traps: POST gets the cached GET, no-store is stored, and X-Last-Modified: None
Update 2026-11-08: I rebuilt every repro on Flask 2.2.3 with Werkzeug 2.2.3 and Flask-Caching 2.0.2, and on Flask 3.1.3 with Werkzeug 3.1.9 and Flask-Caching 2.5.1, under Python 3.11.
A Flask view decorator I kept for years failed in a way that only showed up when a POST got the body of an earlier GET. The view never ran, and the client got a 200:
GET /item 200 "GET ran" X-Cache: MISS
POST /item 200 "GET ran" X-Cache: HIT from Server
The cache key was request.full_path, so any two requests with the same URL shared an entry, regardless of method, user, or Accept header. To fix this, let only anonymous GET and HEAD requests touch the shared entry, build the key from the response inputs, and leave the HTTP headers to Werkzeug.
The decorator entered a 2018 project with a docstring pointing at a Code Review question
and Jake Archibald’s caching article
. I rebuilt each case from rereading it. My routes were all GET-only and public, so the POST and private cases never fired, and I have no logs showing what the others cost anyone.
A decorator with one shared entry per representation #
from functools import wraps
from urllib.parse import urlencode
from flask import make_response, request
from flask_caching import Cache
cache = Cache()
def cached(params=(), offers=("application/json",), server_timeout=300):
"""Server-cache a public view. Declare the query parameters it reads."""
def decorator(view):
@wraps(view)
def wrapper(*args, **kwargs):
if (
request.method not in ("GET", "HEAD")
or request.authorization
or request.cookies
):
return view(*args, **kwargs)
query = urlencode([(p, request.args.getlist(p)) for p in params], doseq=True)
media_type = request.accept_mimetypes.best_match(offers, offers[0])
key = f"view/{request.path}?{query}#{media_type}"
response = cache.get(key)
if response is None:
response = make_response(view(*args, **kwargs))
if response.status_code != 200:
return response
response.add_etag()
response.cache_control.public = True
response.cache_control.no_cache = True
response.vary.add("Accept")
cache.set(key, response, timeout=server_timeout)
return response.make_conditional(request)
return wrapper
return decorator
Both decorators show the same probes, identical on both version sets:
| Probe | Old decorator | Fixed decorator |
|---|---|---|
| POST after GET | Cached GET body, view skipped | View runs |
| Same URL, cookies for two users | Second user sees the first user’s page | Each sees their own |
| Anonymous request after a logged-in one | 200 with the logged-in body | 401 |
JSON, then Accept: text/turtle | JSON for both | Turtle for the second |
100 requests with Cache-Control: no-cache | 100 view runs | 0 after the first |
100 requests with a junk ?x=N | 100 view runs, 100 entries | 0 after the first, 1 entry |
If-None-Match with the W/ form of the tag | 200 | 304 |
| 304 headers | No ETag, X-Last-Modified: None | ETag, Cache-Control, Vary |
Two caveats. Bypassing any cookie is blunt, since one analytics cookie turns the cache off for that browser, so narrow it to your session cookie. And params must list every argument the view reads, or two different URLs collide again. For per-user caching, put the user ID in the key and send private.
no-store and private don’t bind your own cache #
The bare @cached() sent Cache-Control: no-store and still called cache.set:
GET /nostore 200 Cache-Control: no-store X-Cache: MISS
GET /nostore 200 Cache-Control: no-store X-Cache: HIT from Server
no-store
and private
are instructions to the browser and to proxies. An entry your application writes to Redis is outside their reach. The arguments cacheable=False and client_only=True read like server switches and only picked a header. With private, the second user received hello alice from the shared entry.
Decorator order makes it worse. With @cached above a login check, a hit never reaches the check: after one logged-in request, an anonymous client got a 200 with account of alice.
The method guard covered writes only #
request.method in ["GET", "HEAD"] protected cache.set. The cache.get above it had no guard. A POST on a cold cache runs the view, so a test that only sends POST passes.
Flask-Caching’s @cache.cached(timeout=60) did the same on a GET and POST route, in both versions. Add unless=lambda: request.method != "GET" there.
Any client could pick the miss #
if response is not None and request.headers.get("Cache-Control", "") != "no-cache":
The comment says “respect the hard-refresh”. It also hands every client a way to run the expensive view on demand: 100 requests, 100 runs. The comparison is exact, so 100 requests with no-cache, no-store or max-age=0 ran the view once. A junk query parameter does the same and adds an entry per request.
Your application’s cache is part of the origin, and no RFC asks it to obey a client’s request header. Invalidate from the server with cache.delete. The price: a hard refresh shows the cached body until server_timeout passes.
One typo, one bytes object #
The 304 branch read a header that was never written:
response.headers.add("X-Last-Modified", str(now))
headers["X-Last-Modified"] = response.headers.get("X-LastModified") # None
Werkzeug turns None into a string, so every 304 carried X-Last-Modified: None and no error. It also lacked the ETag that RFC 9110
requires on a 304.
The tag was binascii.hexlify(os.urandom(4)), which returns bytes:
| Werkzeug | ETag value |
|---|---|
| 2.2.3 to 2.3.8 | a79d7c42 |
| 3.0.0 and 3.1.9 | b'77155122' |
Neither has the double quotes
that the grammar requires. Both round-trip through If-None-Match unchanged, so the change in Werkzeug 3.0 raises no error. The comparison was a literal ==, where If-None-Match calls for weak comparison
, and a random tag changes every time the entry refills even if the body doesn’t. add_etag() hashes the body and make_conditional() does the comparison.
no-cache means revalidate #
The old comments had this right: “no-cache doesn’t mean ‘don’t cache’” and “must-revalidate doesn’t mean ‘must revalidate’”.
| Directive | Stored? | Reused without asking the server? |
|---|---|---|
no-store | No | No |
no-cache | Yes | No |
must-revalidate, max-age=900 | Yes | For 900 s, then it must revalidate |
Then every call site passed must_revalidate=True, client_timeout=900, which sent no-cache, public, max-age=900. An unqualified no-cache
forces validation on every reuse, so the 900 seconds bought nothing. I had the explanation in the file and still passed both.
Takeaway #
A server-side entry goes to everyone whose request maps to its key, so list what the response depends on and put each item in the key or refuse to cache.
Try it on your own cached views: GET a URL, POST to it, then GET it with another user’s cookie, and fail the test if any two bodies match.