diff --git a/CHANGES/8858.bugfix.rst b/CHANGES/8858.bugfix.rst new file mode 100644 index 00000000000..e4efa91a2fd --- /dev/null +++ b/CHANGES/8858.bugfix.rst @@ -0,0 +1 @@ +Stopped adding a default Content-Type header when response has no content -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/helpers.py b/aiohttp/helpers.py index 26b953ea7ce..2be5977c490 100644 --- a/aiohttp/helpers.py +++ b/aiohttp/helpers.py @@ -49,7 +49,7 @@ from urllib.parse import quote from urllib.request import getproxies, proxy_bypass -from multidict import CIMultiDict, MultiDict, MultiDictProxy +from multidict import CIMultiDict, MultiDict, MultiDictProxy, MultiMapping from yarl import URL from . import hdrs @@ -753,13 +753,15 @@ def ceil_timeout( class HeadersMixin: __slots__ = ("_content_type", "_content_dict", "_stored_content_type") + _headers: MultiMapping[str] + def __init__(self) -> None: super().__init__() self._content_type: Optional[str] = None self._content_dict: Optional[Dict[str, str]] = None - self._stored_content_type: Union[str, _SENTINEL] = sentinel + self._stored_content_type: Union[str, None, _SENTINEL] = sentinel - def _parse_content_type(self, raw: str) -> None: + def _parse_content_type(self, raw: Optional[str]) -> None: self._stored_content_type = raw if raw is None: # default value according to RFC 2616 @@ -774,25 +776,25 @@ def _parse_content_type(self, raw: str) -> None: @property def content_type(self) -> str: """The value of content part for Content-Type HTTP header.""" - raw = self._headers.get(hdrs.CONTENT_TYPE) # type: ignore[attr-defined] + raw = self._headers.get(hdrs.CONTENT_TYPE) if self._stored_content_type != raw: self._parse_content_type(raw) - return self._content_type # type: ignore[return-value] + assert self._content_type is not None + return self._content_type @property def charset(self) -> Optional[str]: """The value of charset part for Content-Type HTTP header.""" - raw = self._headers.get(hdrs.CONTENT_TYPE) # type: ignore[attr-defined] + raw = self._headers.get(hdrs.CONTENT_TYPE) if self._stored_content_type != raw: self._parse_content_type(raw) - return self._content_dict.get("charset") # type: ignore[union-attr] + assert self._content_dict is not None + return self._content_dict.get("charset") @property def content_length(self) -> Optional[int]: """The value of Content-Length HTTP header.""" - content_length = self._headers.get( # type: ignore[attr-defined] - hdrs.CONTENT_LENGTH - ) + content_length = self._headers.get(hdrs.CONTENT_LENGTH) if content_length is not None: return int(content_length) diff --git a/aiohttp/web_request.py b/aiohttp/web_request.py index 828776d82e5..4dbc19a4849 100644 --- a/aiohttp/web_request.py +++ b/aiohttp/web_request.py @@ -82,7 +82,7 @@ class FileField: filename: str file: io.BufferedReader content_type: str - headers: "CIMultiDictProxy[str]" + headers: CIMultiDictProxy[str] _TCHAR: Final[str] = string.digits + string.ascii_letters + r"!#$%&'*+.^_`|~-" @@ -171,7 +171,7 @@ def __init__( self._payload_writer = payload_writer self._payload = payload - self._headers = message.headers + self._headers: CIMultiDictProxy[str] = message.headers self._method = message.method self._version = message.version self._cache: Dict[str, Any] = {} @@ -483,7 +483,7 @@ def query_string(self) -> str: return self._rel_url.query_string @reify - def headers(self) -> "CIMultiDictProxy[str]": + def headers(self) -> CIMultiDictProxy[str]: """A case-insensitive multidict proxy with all headers.""" return self._headers diff --git a/aiohttp/web_response.py b/aiohttp/web_response.py index 47d75675df0..e1ca8ba3fe2 100644 --- a/aiohttp/web_response.py +++ b/aiohttp/web_response.py @@ -402,7 +402,8 @@ async def _prepare_headers(self) -> None: # https://datatracker.ietf.org/doc/html/rfc9112#section-6.1-13 if hdrs.TRANSFER_ENCODING in headers: del headers[hdrs.TRANSFER_ENCODING] - else: + elif self.content_length != 0: + # https://www.rfc-editor.org/rfc/rfc9110#section-8.3-5 headers.setdefault(hdrs.CONTENT_TYPE, "application/octet-stream") headers.setdefault(hdrs.DATE, rfc822_formatted_time()) headers.setdefault(hdrs.SERVER, SERVER_SOFTWARE) diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 3ab798771d5..ce530eb619a 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -759,7 +759,6 @@ async def handler(request: web.Request) -> web.Response: raw_headers = tuple((bytes(h), bytes(v)) for h, v in resp.raw_headers) assert raw_headers == ( (b"Content-Length", b"0"), - (b"Content-Type", b"application/octet-stream"), (b"Date", mock.ANY), (b"Server", mock.ANY), ) @@ -792,7 +791,6 @@ async def handler(request: web.Request) -> web.Response: assert raw_headers == ( (b"X-Empty", b""), (b"Content-Length", b"0"), - (b"Content-Type", b"application/octet-stream"), (b"Date", mock.ANY), (b"Server", mock.ANY), ) diff --git a/tests/test_web_functional.py b/tests/test_web_functional.py index d82fee43623..565efc7b4ca 100644 --- a/tests/test_web_functional.py +++ b/tests/test_web_functional.py @@ -150,6 +150,21 @@ async def handler(request): assert resp.headers["Content-Length"] == "4" +@pytest.mark.parametrize("status", (201, 204, 404)) +async def test_default_content_type_no_body(aiohttp_client: Any, status: int) -> None: + async def handler(request): + return web.Response(status=status) + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + async with client.get("/") as resp: + assert resp.status == status + assert await resp.read() == b"" + assert "Content-Type" not in resp.headers + + async def test_response_before_complete(aiohttp_client: Any) -> None: async def handler(request): return web.Response(body=b"OK") diff --git a/tests/test_web_response.py b/tests/test_web_response.py index 74c2990cce7..f90eb4b1153 100644 --- a/tests/test_web_response.py +++ b/tests/test_web_response.py @@ -1006,7 +1006,6 @@ async def test_send_headers_for_empty_body(buf: Any, writer: Any) -> None: Matches( "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n" - "Content-Type: application/octet-stream\r\n" "Date: .+\r\n" "Server: .+\r\n\r\n" ) @@ -1049,7 +1048,6 @@ async def test_send_set_cookie_header(buf: Any, writer: Any) -> None: "HTTP/1.1 200 OK\r\n" "Content-Length: 0\r\n" "Set-Cookie: name=value\r\n" - "Content-Type: application/octet-stream\r\n" "Date: .+\r\n" "Server: .+\r\n\r\n" )