Skip to content

Commit 3915d7a

Browse files
authored
Fix Content-Length header regression for requests with None body (aio-libs#11035)
1 parent 1172dee commit 3915d7a

3 files changed

Lines changed: 182 additions & 28 deletions

File tree

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
@@ -1055,12 +1055,6 @@ def update_transfer_encoding(self) -> None:
10551055
)
10561056

10571057
self.headers[hdrs.TRANSFER_ENCODING] = "chunked"
1058-
elif (
1059-
self._body is not None
1060-
and hdrs.CONTENT_LENGTH not in self.headers
1061-
and (size := self._body.size) is not None
1062-
):
1063-
self.headers[hdrs.CONTENT_LENGTH] = str(size)
10641058

10651059
def update_auth(self, auth: Optional[BasicAuth], trust_env: bool = False) -> None:
10661060
"""Set basic auth."""
@@ -1085,6 +1079,13 @@ def update_body_from_data(self, body: Any, _stacklevel: int = 3) -> None:
10851079

10861080
if body is None:
10871081
self._body = None
1082+
# Set Content-Length to 0 when body is None for methods that expect a body
1083+
if (
1084+
self.method not in self.GET_METHODS
1085+
and not self.chunked
1086+
and hdrs.CONTENT_LENGTH not in self.headers
1087+
):
1088+
self.headers[hdrs.CONTENT_LENGTH] = "0"
10881089
return
10891090

10901091
# FormData

tests/test_client_request.py

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

218218
def test_host_port_none_port(make_request: _RequestMaker) -> None:
219219
req = make_request("get", "unix://localhost/path")
220-
assert req.headers["Host"] == "localhost"
220+
assert req.headers[hdrs.HOST] == "localhost"
221221

222222

223223
def test_host_port_err(make_request: _RequestMaker) -> None:
@@ -232,17 +232,17 @@ def test_hostname_err(make_request: _RequestMaker) -> None:
232232

233233
def test_host_header_host_first(make_request: _RequestMaker) -> None:
234234
req = make_request("get", "http://python.org/")
235-
assert list(req.headers)[0] == "Host"
235+
assert list(req.headers)[0] == hdrs.HOST
236236

237237

238238
def test_host_header_host_without_port(make_request: _RequestMaker) -> None:
239239
req = make_request("get", "http://python.org/")
240-
assert req.headers["HOST"] == "python.org"
240+
assert req.headers[hdrs.HOST] == "python.org"
241241

242242

243243
def test_host_header_host_with_default_port(make_request: _RequestMaker) -> None:
244244
req = make_request("get", "http://python.org:80/")
245-
assert req.headers["HOST"] == "python.org"
245+
assert req.headers[hdrs.HOST] == "python.org"
246246

247247

248248
def test_host_header_host_with_nondefault_port(make_request: _RequestMaker) -> None:
@@ -353,12 +353,12 @@ def test_skip_default_useragent_header(make_request: _RequestMaker) -> None:
353353

354354
def test_headers(make_request: _RequestMaker) -> None:
355355
req = make_request(
356-
"post", "http://python.org/", headers={"Content-Type": "text/plain"}
356+
"post", "http://python.org/", headers={hdrs.CONTENT_TYPE: "text/plain"}
357357
)
358358

359-
assert "CONTENT-TYPE" in req.headers
360-
assert req.headers["CONTENT-TYPE"] == "text/plain"
361-
assert req.headers["ACCEPT-ENCODING"] == "gzip, deflate, br"
359+
assert hdrs.CONTENT_TYPE in req.headers
360+
assert req.headers[hdrs.CONTENT_TYPE] == "text/plain"
361+
assert req.headers[hdrs.ACCEPT_ENCODING] == "gzip, deflate, br"
362362

363363

364364
def test_headers_list(make_request: _RequestMaker) -> None:
@@ -1034,7 +1034,7 @@ async def test_body_with_size_sets_content_length(
10341034
async def test_body_payload_with_size_no_content_length(
10351035
loop: asyncio.AbstractEventLoop,
10361036
) -> None:
1037-
"""Test that when a body payload with size is set directly, Content-Length is added."""
1037+
"""Test that when a body payload is set via update_body, Content-Length is added."""
10381038
# Create a payload with a known size
10391039
data = b"payload data"
10401040
bytes_payload = payload.BytesPayload(data)
@@ -1046,23 +1046,28 @@ async def test_body_payload_with_size_no_content_length(
10461046
loop=loop,
10471047
)
10481048

1049-
# Set body directly (bypassing update_body_from_data to avoid it setting Content-Length)
1050-
req._body = bytes_payload
1051-
1052-
# Ensure conditions for the code path we want to test
1053-
assert req._body is not None
1054-
assert hdrs.CONTENT_LENGTH not in req.headers
1055-
assert req._body.size is not None
1056-
assert not req.chunked
1049+
# Initially no body should be set
1050+
assert req._body is None
1051+
# POST method with None body should have Content-Length: 0
1052+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
10571053

1058-
# Now trigger update_transfer_encoding which should set Content-Length
1059-
req.update_transfer_encoding()
1054+
# Update body using the public method
1055+
await req.update_body(bytes_payload)
10601056

10611057
# Verify Content-Length was set from body.size
1062-
assert req.headers["CONTENT-LENGTH"] == str(len(data))
1058+
assert req.headers[hdrs.CONTENT_LENGTH] == str(len(data))
10631059
assert req.body is bytes_payload
10641060
assert req._body is bytes_payload # Access _body which is the Payload
1061+
assert req._body is not None # type: ignore[unreachable]
10651062
assert req._body.size == len(data)
1063+
1064+
# Set body back to None
1065+
await req.update_body(None)
1066+
1067+
# Verify Content-Length is back to 0 for POST with None body
1068+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
1069+
assert req._body is None
1070+
10661071
await req.close()
10671072

10681073

@@ -2032,8 +2037,8 @@ async def test_update_body_updates_content_length(
20322037

20332038
# Clear body
20342039
await req.update_body(None)
2035-
# For None body, Content-Length should not be set
2036-
assert "Content-Length" not in req.headers
2040+
# For None body with POST method, Content-Length should be set to 0
2041+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
20372042

20382043
await req.close()
20392044

@@ -2127,4 +2132,149 @@ async def test_expect100_with_body_becomes_none() -> None:
21272132
req._body = None
21282133

21292134
await req.write_bytes(mock_writer, mock_conn, None)
2135+
2136+
2137+
@pytest.mark.parametrize(
2138+
("method", "data", "expected_content_length"),
2139+
[
2140+
# GET methods should not have Content-Length with None body
2141+
("GET", None, None),
2142+
("HEAD", None, None),
2143+
("OPTIONS", None, None),
2144+
("TRACE", None, None),
2145+
# POST methods should have Content-Length: 0 with None body
2146+
("POST", None, "0"),
2147+
("PUT", None, "0"),
2148+
("PATCH", None, "0"),
2149+
("DELETE", None, "0"),
2150+
# Empty bytes should always set Content-Length: 0
2151+
("GET", b"", "0"),
2152+
("HEAD", b"", "0"),
2153+
("POST", b"", "0"),
2154+
("PUT", b"", "0"),
2155+
# Non-empty bytes should set appropriate Content-Length
2156+
("GET", b"test", "4"),
2157+
("POST", b"test", "4"),
2158+
("PUT", b"hello world", "11"),
2159+
("PATCH", b"data", "4"),
2160+
("DELETE", b"x", "1"),
2161+
],
2162+
)
2163+
def test_content_length_for_methods(
2164+
method: str,
2165+
data: Optional[bytes],
2166+
expected_content_length: Optional[str],
2167+
loop: asyncio.AbstractEventLoop,
2168+
) -> None:
2169+
"""Test that Content-Length header is set correctly for all HTTP methods."""
2170+
req = ClientRequest(method, URL("http://python.org/"), data=data, loop=loop)
2171+
2172+
actual_content_length = req.headers.get(hdrs.CONTENT_LENGTH)
2173+
assert actual_content_length == expected_content_length
2174+
2175+
2176+
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "TRACE"])
2177+
def test_get_methods_classification(method: str) -> None:
2178+
"""Test that GET-like methods are correctly classified."""
2179+
assert method in ClientRequest.GET_METHODS
2180+
2181+
2182+
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
2183+
def test_non_get_methods_classification(method: str) -> None:
2184+
"""Test that POST-like methods are not in GET_METHODS."""
2185+
assert method not in ClientRequest.GET_METHODS
2186+
2187+
2188+
async def test_content_length_with_string_data(loop: asyncio.AbstractEventLoop) -> None:
2189+
"""Test Content-Length when data is a string."""
2190+
data = "Hello, World!"
2191+
req = ClientRequest("POST", URL("http://python.org/"), data=data, loop=loop)
2192+
# String should be encoded to bytes, default encoding is utf-8
2193+
assert req.headers[hdrs.CONTENT_LENGTH] == str(len(data.encode("utf-8")))
2194+
await req.close()
2195+
2196+
2197+
async def test_content_length_with_async_iterable(
2198+
loop: asyncio.AbstractEventLoop,
2199+
) -> None:
2200+
"""Test that async iterables use chunked encoding, not Content-Length."""
2201+
2202+
async def data_gen() -> AsyncIterator[bytes]:
2203+
yield b"chunk1" # pragma: no cover
2204+
2205+
req = ClientRequest("POST", URL("http://python.org/"), data=data_gen(), loop=loop)
2206+
assert hdrs.CONTENT_LENGTH not in req.headers
2207+
assert req.chunked
2208+
assert req.headers[hdrs.TRANSFER_ENCODING] == "chunked"
2209+
await req.close()
2210+
2211+
2212+
async def test_content_length_not_overridden(loop: asyncio.AbstractEventLoop) -> None:
2213+
"""Test that explicitly set Content-Length is not overridden."""
2214+
req = ClientRequest(
2215+
"POST",
2216+
URL("http://python.org/"),
2217+
data=b"test",
2218+
headers={hdrs.CONTENT_LENGTH: "100"},
2219+
loop=loop,
2220+
)
2221+
# Should keep the explicitly set value
2222+
assert req.headers[hdrs.CONTENT_LENGTH] == "100"
2223+
await req.close()
2224+
2225+
2226+
async def test_content_length_with_formdata(loop: asyncio.AbstractEventLoop) -> None:
2227+
"""Test Content-Length with FormData."""
2228+
form = aiohttp.FormData()
2229+
form.add_field("field", "value")
2230+
2231+
req = ClientRequest("POST", URL("http://python.org/"), data=form, loop=loop)
2232+
# FormData with known size should set Content-Length
2233+
assert hdrs.CONTENT_LENGTH in req.headers
2234+
await req.close()
2235+
2236+
2237+
async def test_no_content_length_with_chunked(loop: asyncio.AbstractEventLoop) -> None:
2238+
"""Test that chunked encoding prevents Content-Length header."""
2239+
req = ClientRequest(
2240+
"POST",
2241+
URL("http://python.org/"),
2242+
data=b"test",
2243+
chunked=True,
2244+
loop=loop,
2245+
)
2246+
assert hdrs.CONTENT_LENGTH not in req.headers
2247+
assert req.headers[hdrs.TRANSFER_ENCODING] == "chunked"
2248+
await req.close()
2249+
2250+
2251+
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
2252+
async def test_update_body_none_sets_content_length_zero(
2253+
method: str, loop: asyncio.AbstractEventLoop
2254+
) -> None:
2255+
"""Test that updating body to None sets Content-Length: 0 for POST-like methods."""
2256+
# Create request with initial body
2257+
req = ClientRequest(method, URL("http://python.org/"), data=b"initial", loop=loop)
2258+
assert req.headers[hdrs.CONTENT_LENGTH] == "7"
2259+
2260+
# Update body to None
2261+
await req.update_body(None)
2262+
assert req.headers[hdrs.CONTENT_LENGTH] == "0"
2263+
assert req._body is None
2264+
await req.close()
2265+
2266+
2267+
@pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "TRACE"])
2268+
async def test_update_body_none_no_content_length_for_get_methods(
2269+
method: str, loop: asyncio.AbstractEventLoop
2270+
) -> None:
2271+
"""Test that updating body to None doesn't set Content-Length for GET-like methods."""
2272+
# Create request with initial body
2273+
req = ClientRequest(method, URL("http://python.org/"), data=b"initial", loop=loop)
2274+
assert req.headers[hdrs.CONTENT_LENGTH] == "7"
2275+
2276+
# Update body to None
2277+
await req.update_body(None)
2278+
assert hdrs.CONTENT_LENGTH not in req.headers
2279+
assert req._body is None
21302280
await req.close()

0 commit comments

Comments
 (0)