Skip to content

Commit 6c3daa7

Browse files
[PR #11035/3915d7a6 backport][3.12] Fix Content-Length header regression for requests with None body (#11037)
Co-authored-by: J. Nick Koston <[email protected]>
1 parent 857229c commit 6c3daa7

File tree

3 files changed

+182
-28
lines changed

3 files changed

+182
-28
lines changed

CHANGES/11035.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed ``Content-Length`` header not being set to ``0`` for non-GET requests with ``None`` body -- by :user:`bdraco`.
2+
3+
Non-GET requests (``POST``, ``PUT``, ``PATCH``, ``DELETE``) with ``None`` as the body now correctly set the ``Content-Length`` header to ``0``, matching the behavior of requests with empty bytes (``b""``). This regression was introduced in aiohttp 3.12.1.

aiohttp/client_reqrep.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,12 +1136,6 @@ def update_transfer_encoding(self) -> None:
11361136
)
11371137

11381138
self.headers[hdrs.TRANSFER_ENCODING] = "chunked"
1139-
elif (
1140-
self._body is not None
1141-
and hdrs.CONTENT_LENGTH not in self.headers
1142-
and (size := self._body.size) is not None
1143-
):
1144-
self.headers[hdrs.CONTENT_LENGTH] = str(size)
11451139

11461140
def update_auth(self, auth: Optional[BasicAuth], trust_env: bool = False) -> None:
11471141
"""Set basic auth."""
@@ -1166,6 +1160,13 @@ def update_body_from_data(self, body: Any, _stacklevel: int = 3) -> None:
11661160

11671161
if body is None:
11681162
self._body = None
1163+
# Set Content-Length to 0 when body is None for methods that expect a body
1164+
if (
1165+
self.method not in self.GET_METHODS
1166+
and not self.chunked
1167+
and hdrs.CONTENT_LENGTH not in self.headers
1168+
):
1169+
self.headers[hdrs.CONTENT_LENGTH] = "0"
11691170
return
11701171

11711172
# FormData

tests/test_client_request.py

Lines changed: 172 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def test_host_port_nondefault_wss(make_request) -> None:
205205

206206
def test_host_port_none_port(make_request) -> None:
207207
req = make_request("get", "unix://localhost/path")
208-
assert req.headers["Host"] == "localhost"
208+
assert req.headers[hdrs.HOST] == "localhost"
209209

210210

211211
def test_host_port_err(make_request) -> None:
@@ -220,17 +220,17 @@ def test_hostname_err(make_request) -> None:
220220

221221
def test_host_header_host_first(make_request) -> None:
222222
req = make_request("get", "http://python.org/")
223-
assert list(req.headers)[0] == "Host"
223+
assert list(req.headers)[0] == hdrs.HOST
224224

225225

226226
def test_host_header_host_without_port(make_request) -> None:
227227
req = make_request("get", "http://python.org/")
228-
assert req.headers["HOST"] == "python.org"
228+
assert req.headers[hdrs.HOST] == "python.org"
229229

230230

231231
def test_host_header_host_with_default_port(make_request) -> None:
232232
req = make_request("get", "http://python.org:80/")
233-
assert req.headers["HOST"] == "python.org"
233+
assert req.headers[hdrs.HOST] == "python.org"
234234

235235

236236
def test_host_header_host_with_nondefault_port(make_request) -> None:
@@ -348,12 +348,12 @@ def test_skip_default_useragent_header(make_request) -> None:
348348

349349
def test_headers(make_request) -> None:
350350
req = make_request(
351-
"post", "http://python.org/", headers={"Content-Type": "text/plain"}
351+
"post", "http://python.org/", headers={hdrs.CONTENT_TYPE: "text/plain"}
352352
)
353353

354-
assert "CONTENT-TYPE" in req.headers
355-
assert req.headers["CONTENT-TYPE"] == "text/plain"
356-
assert req.headers["ACCEPT-ENCODING"] == "gzip, deflate, br"
354+
assert hdrs.CONTENT_TYPE in req.headers
355+
assert req.headers[hdrs.CONTENT_TYPE] == "text/plain"
356+
assert req.headers[hdrs.ACCEPT_ENCODING] == "gzip, deflate, br"
357357

358358

359359
def test_headers_list(make_request) -> None:
@@ -979,7 +979,7 @@ async def test_body_with_size_sets_content_length(
979979
async def test_body_payload_with_size_no_content_length(
980980
loop: asyncio.AbstractEventLoop,
981981
) -> None:
982-
"""Test that when a body payload with size is set directly, Content-Length is added."""
982+
"""Test that when a body payload is set via update_body, Content-Length is added."""
983983
# Create a payload with a known size
984984
data = b"payload data"
985985
bytes_payload = payload.BytesPayload(data)
@@ -991,23 +991,28 @@ async def test_body_payload_with_size_no_content_length(
991991
loop=loop,
992992
)
993993

994-
# Set body directly (bypassing update_body_from_data to avoid it setting Content-Length)
995-
req._body = bytes_payload
996-
997-
# Ensure conditions for the code path we want to test
998-
assert req._body is not None
999-
assert hdrs.CONTENT_LENGTH not in req.headers
1000-
assert req._body.size is not None
1001-
assert not req.chunked
994+
# Initially no body should be set
995+
assert req._body is None
996+
# POST method with None body should have Content-Length: 0
997+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
1002998

1003-
# Now trigger update_transfer_encoding which should set Content-Length
1004-
req.update_transfer_encoding()
999+
# Update body using the public method
1000+
await req.update_body(bytes_payload)
10051001

10061002
# Verify Content-Length was set from body.size
1007-
assert req.headers["CONTENT-LENGTH"] == str(len(data))
1003+
assert req.headers[hdrs.CONTENT_LENGTH] == str(len(data))
10081004
assert req.body is bytes_payload
10091005
assert req._body is bytes_payload # Access _body which is the Payload
1006+
assert req._body is not None # type: ignore[unreachable]
10101007
assert req._body.size == len(data)
1008+
1009+
# Set body back to None
1010+
await req.update_body(None)
1011+
1012+
# Verify Content-Length is back to 0 for POST with None body
1013+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
1014+
assert req._body is None
1015+
10111016
await req.close()
10121017

10131018

@@ -1980,8 +1985,8 @@ async def test_update_body_updates_content_length(
19801985

19811986
# Clear body
19821987
await req.update_body(None)
1983-
# For None body, Content-Length should not be set
1984-
assert "Content-Length" not in req.headers
1988+
# For None body with POST method, Content-Length should be set to 0
1989+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
19851990

19861991
await req.close()
19871992

@@ -2075,4 +2080,149 @@ async def test_expect100_with_body_becomes_none() -> None:
20752080
req._body = None
20762081

20772082
await req.write_bytes(mock_writer, mock_conn, None)
2083+
2084+
2085+
@pytest.mark.parametrize(
2086+
("method", "data", "expected_content_length"),
2087+
[
2088+
# GET methods should not have Content-Length with None body
2089+
("GET", None, None),
2090+
("HEAD", None, None),
2091+
("OPTIONS", None, None),
2092+
("TRACE", None, None),
2093+
# POST methods should have Content-Length: 0 with None body
2094+
("POST", None, "0"),
2095+
("PUT", None, "0"),
2096+
("PATCH", None, "0"),
2097+
("DELETE", None, "0"),
2098+
# Empty bytes should always set Content-Length: 0
2099+
("GET", b"", "0"),
2100+
("HEAD", b"", "0"),
2101+
("POST", b"", "0"),
2102+
("PUT", b"", "0"),
2103+
# Non-empty bytes should set appropriate Content-Length
2104+
("GET", b"test", "4"),
2105+
("POST", b"test", "4"),
2106+
("PUT", b"hello world", "11"),
2107+
("PATCH", b"data", "4"),
2108+
("DELETE", b"x", "1"),
2109+
],
2110+
)
2111+
def test_content_length_for_methods(
2112+
method: str,
2113+
data: Optional[bytes],
2114+
expected_content_length: Optional[str],
2115+
loop: asyncio.AbstractEventLoop,
2116+
) -> None:
2117+
"""Test that Content-Length header is set correctly for all HTTP methods."""
2118+
req = ClientRequest(method, URL("http://python.org/"), data=data, loop=loop)
2119+
2120+
actual_content_length = req.headers.get(hdrs.CONTENT_LENGTH)
2121+
assert actual_content_length == expected_content_length
2122+
2123+
2124+
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "TRACE"])
2125+
def test_get_methods_classification(method: str) -> None:
2126+
"""Test that GET-like methods are correctly classified."""
2127+
assert method in ClientRequest.GET_METHODS
2128+
2129+
2130+
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
2131+
def test_non_get_methods_classification(method: str) -> None:
2132+
"""Test that POST-like methods are not in GET_METHODS."""
2133+
assert method not in ClientRequest.GET_METHODS
2134+
2135+
2136+
async def test_content_length_with_string_data(loop: asyncio.AbstractEventLoop) -> None:
2137+
"""Test Content-Length when data is a string."""
2138+
data = "Hello, World!"
2139+
req = ClientRequest("POST", URL("http://python.org/"), data=data, loop=loop)
2140+
# String should be encoded to bytes, default encoding is utf-8
2141+
assert req.headers[hdrs.CONTENT_LENGTH] == str(len(data.encode("utf-8")))
2142+
await req.close()
2143+
2144+
2145+
async def test_content_length_with_async_iterable(
2146+
loop: asyncio.AbstractEventLoop,
2147+
) -> None:
2148+
"""Test that async iterables use chunked encoding, not Content-Length."""
2149+
2150+
async def data_gen() -> AsyncIterator[bytes]:
2151+
yield b"chunk1" # pragma: no cover
2152+
2153+
req = ClientRequest("POST", URL("http://python.org/"), data=data_gen(), loop=loop)
2154+
assert hdrs.CONTENT_LENGTH not in req.headers
2155+
assert req.chunked
2156+
assert req.headers[hdrs.TRANSFER_ENCODING] == "chunked"
2157+
await req.close()
2158+
2159+
2160+
async def test_content_length_not_overridden(loop: asyncio.AbstractEventLoop) -> None:
2161+
"""Test that explicitly set Content-Length is not overridden."""
2162+
req = ClientRequest(
2163+
"POST",
2164+
URL("http://python.org/"),
2165+
data=b"test",
2166+
headers={hdrs.CONTENT_LENGTH: "100"},
2167+
loop=loop,
2168+
)
2169+
# Should keep the explicitly set value
2170+
assert req.headers[hdrs.CONTENT_LENGTH] == "100"
2171+
await req.close()
2172+
2173+
2174+
async def test_content_length_with_formdata(loop: asyncio.AbstractEventLoop) -> None:
2175+
"""Test Content-Length with FormData."""
2176+
form = aiohttp.FormData()
2177+
form.add_field("field", "value")
2178+
2179+
req = ClientRequest("POST", URL("http://python.org/"), data=form, loop=loop)
2180+
# FormData with known size should set Content-Length
2181+
assert hdrs.CONTENT_LENGTH in req.headers
2182+
await req.close()
2183+
2184+
2185+
async def test_no_content_length_with_chunked(loop: asyncio.AbstractEventLoop) -> None:
2186+
"""Test that chunked encoding prevents Content-Length header."""
2187+
req = ClientRequest(
2188+
"POST",
2189+
URL("http://python.org/"),
2190+
data=b"test",
2191+
chunked=True,
2192+
loop=loop,
2193+
)
2194+
assert hdrs.CONTENT_LENGTH not in req.headers
2195+
assert req.headers[hdrs.TRANSFER_ENCODING] == "chunked"
2196+
await req.close()
2197+
2198+
2199+
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
2200+
async def test_update_body_none_sets_content_length_zero(
2201+
method: str, loop: asyncio.AbstractEventLoop
2202+
) -> None:
2203+
"""Test that updating body to None sets Content-Length: 0 for POST-like methods."""
2204+
# Create request with initial body
2205+
req = ClientRequest(method, URL("http://python.org/"), data=b"initial", loop=loop)
2206+
assert req.headers[hdrs.CONTENT_LENGTH] == "7"
2207+
2208+
# Update body to None
2209+
await req.update_body(None)
2210+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
2211+
assert req._body is None
2212+
await req.close()
2213+
2214+
2215+
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "TRACE"])
2216+
async def test_update_body_none_no_content_length_for_get_methods(
2217+
method: str, loop: asyncio.AbstractEventLoop
2218+
) -> None:
2219+
"""Test that updating body to None doesn't set Content-Length for GET-like methods."""
2220+
# Create request with initial body
2221+
req = ClientRequest(method, URL("http://python.org/"), data=b"initial", loop=loop)
2222+
assert req.headers[hdrs.CONTENT_LENGTH] == "7"
2223+
2224+
# Update body to None
2225+
await req.update_body(None)
2226+
assert hdrs.CONTENT_LENGTH not in req.headers
2227+
assert req._body is None
20782228
await req.close()

0 commit comments

Comments
 (0)