Repository: pallets/werkzeug

Issue:
send_file should set conditional=True and no-cache by default
Currently, `send_file` sets `cache_timeout` to 12 hours, and disables `conditional` by default. We see a regular stream of questions from developers who are confused why their CSS changes aren't reflected in development, or even when deploying a new version. 12 hours is *really* long, even CDNs default to around 1-4 hours.

Instead, we should set `conditional=True` by default, set `cache_control.no_cache` instead of `public`, and not set `max_age` or `expires`. This will tell the client to cache the response, but to make conditional requests to decide whether to use the cache.

This will solve the issue developers are having with stale resources. It adds a bit of overhead since it requires a request cycle to calculate if the conditions are still met, but I think this should be negligible for most applications. If an application is at the point where that overhead matters, they should be using a CDN, at which point they would be tuning `cache-control` anyway.

Related to #1850

cc @jab


Discussion:

Resolving patch:
diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py
--- a/src/werkzeug/utils.py
+++ b/src/werkzeug/utils.py
@@ -554,14 +554,14 @@ def append_slash_redirect(environ, code=301):
 
 def send_file(
     path_or_file,
-    environ=None,
+    environ,
     mimetype=None,
     as_attachment=False,
     download_name=None,
+    conditional=True,
     add_etags=True,
-    cache_timeout=43200,
-    conditional=False,
     last_modified=None,
+    max_age=None,
     use_x_sendfile=False,
     response_class=None,
 ):
@@ -594,15 +594,16 @@ def send_file(
         save the file instead of displaying it.
     :param download_name: The default name browsers will use when saving
         the file. Defaults to the passed file name.
-    :param add_etags: Calculate an ETag for the file. Requires passing a
-        file path.
     :param conditional: Enable conditional and range responses based on
         request headers. Requires passing a file path and ``environ``.
-    :param cache_timeout: How long the client should cache the file, in
-        seconds. Default is 12 hours.
+    :param add_etags: Calculate an ETag for the file. Requires passing a
+        file path.
     :param last_modified: The last modified time to send for the file,
         in seconds. If not provided, it will try to detect it from the
-        file path with :func:`os.mtime`.
+        file path.
+    :param max_age: How long the client should cache the file, in
+        seconds. If set, ``Cache-Control`` will be ``public``, otherwise
+        it will be ``no-cache`` to prefer conditional caching.
     :param use_x_sendfile: Set the ``X-Sendfile`` header to let the
         server to efficiently send the file. Requires support from the
         HTTP server. Requires passing a file path.
@@ -616,6 +617,11 @@ def send_file(
         ``download_name`` replaces Flask's ``attachment_filename``
          parameter. If ``as_attachment=False``, it is passed with
          ``Content-Disposition: inline`` instead.
+
+    .. versionchanged:: 2.0.0
+        ``max_age`` replaces Flask's ``cache_timeout`` parameter.
+        ``conditional`` is enabled and ``max_age`` is not set by
+        default.
     """
     if response_class is None:
         from .wrappers import Response as response_class
@@ -677,7 +683,7 @@ def send_file(
         elif isinstance(file, io.TextIOBase):
             raise ValueError("Files must be opened in binary mode or use BytesIO.")
 
-        data = wrap_file(environ or {}, file)
+        data = wrap_file(environ, file)
 
     rv = response_class(
         data, mimetype=mimetype, headers=headers, direct_passthrough=True
@@ -691,20 +697,21 @@ def send_file(
     elif mtime is not None:
         rv.last_modified = mtime
 
-    rv.cache_control.public = True
+    rv.cache_control.no_cache = True
 
-    if cache_timeout is not None:
-        rv.cache_control.max_age = cache_timeout
-        rv.expires = int(time() + cache_timeout)
+    if max_age is not None:
+        if max_age > 0:
+            rv.cache_control.no_cache = None
+            rv.cache_control.public = True
+
+        rv.cache_control.max_age = max_age
+        rv.expires = int(time() + max_age)
 
     if add_etags and path is not None:
         check = adler32(str(path).encode("utf-8")) & 0xFFFFFFFF
         rv.set_etag(f"{mtime}-{size}-{check}")
 
     if conditional:
-        if environ is None:
-            raise TypeError("'environ' is required with 'conditional=True'.")
-
         try:
             rv = rv.make_conditional(environ, accept_ranges=True, complete_length=size)
         except RequestedRangeNotSatisfiable:


Test patch:
diff --git a/tests/test_send_file.py b/tests/test_send_file.py
--- a/tests/test_send_file.py
+++ b/tests/test_send_file.py
@@ -1,10 +1,10 @@
 import datetime
 import io
-import os
 import pathlib
 
 import pytest
 
+from werkzeug.http import http_date
 from werkzeug.test import EnvironBuilder
 from werkzeug.utils import send_file
 
@@ -12,10 +12,12 @@
 html_path = res_path / "index.html"
 txt_path = res_path / "test.txt"
 
+environ = EnvironBuilder().get_environ()
+
 
 @pytest.mark.parametrize("path", [html_path, str(html_path)])
 def test_path(path):
-    rv = send_file(path)
+    rv = send_file(path, environ)
     assert rv.mimetype == "text/html"
     assert rv.direct_passthrough
     rv.direct_passthrough = False
@@ -24,16 +26,15 @@ def test_path(path):
 
 
 def test_x_sendfile():
-    rv = send_file(html_path, use_x_sendfile=True)
+    rv = send_file(html_path, environ, use_x_sendfile=True)
     assert rv.headers["x-sendfile"] == str(html_path)
     assert rv.data == b""
     rv.close()
 
 
 def test_last_modified():
-    environ = EnvironBuilder().get_environ()
     last_modified = datetime.datetime(1999, 1, 1)
-    rv = send_file(txt_path, environ=environ, last_modified=last_modified)
+    rv = send_file(txt_path, environ, last_modified=last_modified)
     assert rv.last_modified == last_modified
     rv.close()
 
@@ -42,7 +43,7 @@ def test_last_modified():
     "file_factory", [lambda: txt_path.open("rb"), lambda: io.BytesIO(b"test")],
 )
 def test_object(file_factory):
-    rv = send_file(file_factory(), mimetype="text/plain", use_x_sendfile=True)
+    rv = send_file(file_factory(), environ, mimetype="text/plain", use_x_sendfile=True)
     rv.direct_passthrough = False
     assert rv.data
     assert rv.mimetype == "text/plain"
@@ -52,11 +53,11 @@ def test_object(file_factory):
 
 def test_object_without_mimetype():
     with pytest.raises(ValueError, match="detect the MIME type"):
-        send_file(io.BytesIO(b"test"))
+        send_file(io.BytesIO(b"test"), environ)
 
 
 def test_object_mimetype_from_name():
-    rv = send_file(io.BytesIO(b"test"), download_name="test.txt")
+    rv = send_file(io.BytesIO(b"test"), environ, download_name="test.txt")
     assert rv.mimetype == "text/plain"
     rv.close()
 
@@ -66,23 +67,27 @@ def test_object_mimetype_from_name():
 )
 def test_text_mode_fails(file_factory):
     with file_factory() as f, pytest.raises(ValueError, match="binary mode"):
-        send_file(f, os.path.realpath(__file__), mimetype="text/plain")
+        send_file(f, environ, mimetype="text/plain")
 
 
 @pytest.mark.parametrize(
     ("as_attachment", "value"), [(False, "inline"), (True, "attachment")]
 )
 def test_disposition_name(as_attachment, value):
-    rv = send_file(txt_path, as_attachment=as_attachment)
+    rv = send_file(txt_path, environ, as_attachment=as_attachment)
     assert rv.headers["Content-Disposition"] == f"{value}; filename=test.txt"
     rv.close()
 
 
 def test_object_attachment_requires_name():
     with pytest.raises(TypeError, match="attachment"):
-        send_file(io.BytesIO(b"test"), mimetype="text/plain", as_attachment=True)
+        send_file(
+            io.BytesIO(b"test"), environ, mimetype="text/plain", as_attachment=True,
+        )
 
-    rv = send_file(io.BytesIO(b"test"), as_attachment=True, download_name="test.txt")
+    rv = send_file(
+        io.BytesIO(b"test"), environ, as_attachment=True, download_name="test.txt",
+    )
     assert rv.headers["Content-Disposition"] == f"attachment; filename=test.txt"
     rv.close()
 
@@ -103,7 +108,7 @@ def test_object_attachment_requires_name():
     ),
 )
 def test_non_ascii_name(name, ascii, utf8):
-    rv = send_file(html_path, as_attachment=True, download_name=name)
+    rv = send_file(html_path, environ, as_attachment=True, download_name=name)
     rv.close()
     content_disposition = rv.headers["Content-Disposition"]
     assert f"filename={ascii}" in content_disposition
@@ -112,3 +117,30 @@ def test_non_ascii_name(name, ascii, utf8):
         assert f"filename*=UTF-8''{utf8}" in content_disposition
     else:
         assert "filename*=UTF-8''" not in content_disposition
+
+
+def test_no_cache_conditional_default():
+    rv = send_file(
+        txt_path,
+        EnvironBuilder(
+            headers={"If-Modified-Since": http_date(datetime.datetime(2020, 7, 12))}
+        ).get_environ(),
+        last_modified=datetime.datetime(2020, 7, 11),
+    )
+    rv.close()
+    assert "no-cache" in rv.headers["Cache-Control"]
+    assert not rv.cache_control.public
+    assert not rv.cache_control.max_age
+    assert not rv.expires
+    assert rv.status_code == 304
+
+
+@pytest.mark.parametrize(("value", "public"), [(0, False), (60, True)])
+def test_max_age(value, public):
+    rv = send_file(txt_path, environ, max_age=value)
+    rv.close()
+    assert ("no-cache" in rv.headers["Cache-Control"]) != public
+    assert rv.cache_control.public == public
+    assert rv.cache_control.max_age == value
+    assert rv.expires
+    assert rv.status_code == 200