Skip to content

Commit cb307c1

Browse files
authored
redirect defaults to 303 (#3092)
2 parents 06f33b2 + 6e2dfd0 commit cb307c1

File tree

6 files changed

+83
-52
lines changed

6 files changed

+83
-52
lines changed

CHANGES.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ Version 3.2.0
55

66
Unreleased
77

8+
- ``redirect`` returns a ``303`` status code by default instead of ``302``.
9+
This tells the client to always switch to ``GET``, rather than only
10+
switching ``POST`` to ``GET``. This preserves the current behavior of
11+
``GET`` and ``POST`` redirects, and is also correct for frontend libraries
12+
such as HTMX. :pr:`3092`
13+
- The test client clears more request body information when a redirect
14+
switches to ``GET``. :pr:`3092`
15+
- The test client only switches ``301`` and ``302`` redirects to ``GET`` if
16+
the request was ``POST``. :pr:`3092`
17+
- The test client does not handle ``305`` as a redirect, as it is no longer
18+
part of the HTTP spec. :pr:`3092`
19+
- ``EnvironBuilder.close`` closes all open files in ``files`` rather than only
20+
the first for each key. :pr:`3092`
21+
822

923
Version 3.1.5
1024
-------------

src/werkzeug/datastructures/file_storage.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,16 @@ def add_file(
204204

205205
self.add(name, FileStorage(file_obj, filename, name, content_type))
206206

207+
def close(self) -> None:
208+
"""Call :meth:`~FileStorage.close` on every open file.
209+
210+
.. versionadded:: 3.2
211+
"""
212+
for values in self.listvalues():
213+
for value in values:
214+
if not value.closed:
215+
value.close()
216+
207217

208218
# circular dependencies
209219
from .. import http # noqa: E402

src/werkzeug/test.py

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -653,15 +653,10 @@ def close(self) -> None:
653653
"""
654654
if self.closed:
655655
return
656-
try:
657-
files = self.files.values()
658-
except AttributeError:
659-
files = ()
660-
for f in files:
661-
try:
662-
f.close()
663-
except Exception:
664-
pass
656+
657+
if self._files is not None:
658+
self.files.close()
659+
665660
self.closed = True
666661

667662
def get_environ(self) -> WSGIEnvironment:
@@ -1037,20 +1032,23 @@ def resolve_redirect(
10371032
builder.path = path
10381033
builder.script_root = ""
10391034

1040-
# Only 307 and 308 preserve all of the original request.
1041-
if response.status_code not in {307, 308}:
1042-
# HEAD is preserved, everything else becomes GET.
1043-
if builder.method != "HEAD":
1044-
builder.method = "GET"
1045-
1046-
# Clear the body and the headers that describe it.
1035+
# Certain statuses switch to GET in some cases
1036+
# https://fetch.spec.whatwg.org/#http-redirect-fetch
1037+
if (response.status_code in {301, 302} and builder.method == "POST") or (
1038+
response.status_code == 303 and builder.method not in {"GET", "HEAD"}
1039+
):
1040+
builder.method = "GET"
10471041

10481042
if builder.input_stream is not None:
10491043
builder.input_stream.close()
1050-
builder.input_stream = None
10511044

1045+
builder.close()
1046+
builder.input_stream = None
10521047
builder.content_type = None
10531048
builder.content_length = None
1049+
builder.headers.pop("Content-Encoding", None)
1050+
builder.headers.pop("Content-Language", None)
1051+
builder.headers.pop("Content-Location", None)
10541052
builder.headers.pop("Transfer-Encoding", None)
10551053

10561054
return self.open(builder, buffered=buffered)
@@ -1122,14 +1120,7 @@ def open(
11221120
if not follow_redirects:
11231121
return response
11241122

1125-
while response.status_code in {
1126-
301,
1127-
302,
1128-
303,
1129-
305,
1130-
307,
1131-
308,
1132-
}:
1123+
while response.status_code in {301, 302, 303, 307, 308}:
11331124
# Exhaust intermediate response bodies to ensure middleware
11341125
# that returns an iterator runs any cleanup code.
11351126
if not buffered:

src/werkzeug/utils.py

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -233,26 +233,43 @@ def secure_filename(filename: str) -> str:
233233

234234

235235
def redirect(
236-
location: str, code: int = 302, Response: type[Response] | None = None
236+
location: str, code: int = 303, Response: type[Response] | None = None
237237
) -> Response:
238-
"""Returns a response object (a WSGI application) that, if called,
239-
redirects the client to the target location. Supported codes are
240-
301, 302, 303, 305, 307, and 308. 300 is not supported because
241-
it's not a real redirect and 304 because it's the answer for a
242-
request with a request with defined If-Modified-Since headers.
243-
244-
.. versionadded:: 0.6
245-
The location can now be a unicode string that is encoded using
246-
the :func:`iri_to_uri` function.
247-
248-
.. versionadded:: 0.10
249-
The class used for the Response object can now be passed in.
250-
251-
:param location: the location the response should redirect to.
252-
:param code: the redirect status code. defaults to 302.
253-
:param class Response: a Response class to use when instantiating a
254-
response. The default is :class:`werkzeug.wrappers.Response` if
255-
unspecified.
238+
"""Create a response that redirects the client to the target location.
239+
240+
The default ``303 See Other`` status code instructs the client to make a
241+
``GET`` request to the target, regardless of what method the current request
242+
is. This produces the correct result for the common use cases: page redirects
243+
and form success. The status codes you're likely to use are:
244+
245+
- ``303 See Other`` always uses a ``GET`` request.
246+
- ``307 Temporary Redirect`` preserves the current method.
247+
- ``308 Permanent Redirect`` preserves the current method, and instructs
248+
the client to permanently apply the result. This is hard to undo once
249+
you've sent it, so be sure the permanence is what you want.
250+
251+
Two older codes, ``302 Found`` and ``301 Moved Permanently``, are
252+
superseded by ``307`` and ``308`` respectively. They were not consistently
253+
implemented by clients, which tend to switch ``POST`` to ``GET`` but
254+
preserve other methods. Prefer using ``303``, ``307``, and ``308`` to get
255+
the exact behavior you intend. Other ``3xx`` codes are either not defined or
256+
have specific use cases.
257+
258+
:param location: The URL to redirect to. The client will interpret a
259+
relative URL (without the host) as relative to the host it's accessing.
260+
:param code: The redirect status code. This affects how the client issues
261+
the next request. Defaults to ``303``.
262+
:param Response: The response class. Defaults to
263+
:class:`werkzeug.wrappers.Response`.
264+
265+
.. versionchanged:: 3.2
266+
``code`` defaults to 303 instead of 302.
267+
268+
.. versionchanged:: 0.10
269+
Added the ``response`` parameter.
270+
271+
.. versionchanged:: 0.6
272+
``location`` can contain Unicode characters.
256273
"""
257274
if Response is None:
258275
from .wrappers import Response

tests/test_test.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io
12
import json
23
import sys
34
from functools import partial
@@ -425,12 +426,10 @@ def test_builder_from_environ():
425426
def test_file_closing():
426427
closed = []
427428

428-
class SpecialInput:
429-
def read(self, size):
430-
return b""
431-
429+
class SpecialInput(io.BytesIO):
432430
def close(self):
433431
closed.append(self)
432+
super().close()
434433

435434
create_environ(data={"foo": SpecialInput()})
436435
assert len(closed) == 1
@@ -593,13 +592,13 @@ def app(request):
593592
assert len(response.history) == 2
594593

595594
assert response.history[-1].request.path == "/second"
596-
assert response.history[-1].status_code == 302
595+
assert response.history[-1].status_code == 303
597596
assert response.history[-1].location == "/third"
598597
assert len(response.history[-1].history) == 1
599598
assert response.history[-1].history[-1] is response.history[-2]
600599

601600
assert response.history[-2].request.path == "/first"
602-
assert response.history[-2].status_code == 302
601+
assert response.history[-2].status_code == 303
603602
assert response.history[-2].location == "/second"
604603
assert len(response.history[-2].history) == 0
605604

tests/test_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
("url", "code", "expect"),
2020
[
2121
("http://example.com", None, "http://example.com"),
22-
("/füübär", 305, "/f%C3%BC%C3%BCb%C3%A4r"),
22+
("/füübär", 301, "/f%C3%BC%C3%BCb%C3%A4r"),
2323
("http://☃.example.com/", 307, "http://xn--n3h.example.com/"),
2424
("itms-services://?url=abc", None, "itms-services://?url=abc"),
2525
],
@@ -29,7 +29,7 @@ def test_redirect(url: str, code: int | None, expect: str) -> None:
2929

3030
if code is None:
3131
resp = utils.redirect(url)
32-
assert resp.status_code == 302
32+
assert resp.status_code == 303
3333
else:
3434
resp = utils.redirect(url, code)
3535
assert resp.status_code == code

0 commit comments

Comments
 (0)