From 41fb3072b7b30983f07d052348a1dd4116357af9 Mon Sep 17 00:00:00 2001 From: vhinayindia <281186805+vhinayindia@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:16:50 +0530 Subject: [PATCH] Handle Content-Length on 204 and 304 responses --- src/h2/stream.py | 11 +++++- tests/test_invalid_content_lengths.py | 53 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/h2/stream.py b/src/h2/stream.py index 249f73e0c..586ccaa73 100644 --- a/src/h2/stream.py +++ b/src/h2/stream.py @@ -1365,8 +1365,12 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: return content_length = None + response_status: bytes | None = None for n, v in headers: + if n == b":status": + response_status = v + if n == b"content-length": if not v.isdigit(): # https://www.rfc-editor.org/rfc/rfc9110.html#name-content-length @@ -1385,7 +1389,12 @@ def _initialize_content_length(self, headers: Iterable[Header]) -> None: msg = f"Conflicting content-length headers: {content_length} and {parsed_content_length}" raise ProtocolError(msg) - self._expected_content_length = content_length + # These responses have no content, so Content-Length is not the size + # of any DATA frames (RFC 9113, ยง 8.1.1). + if response_status in {b"204", b"304"}: + self._expected_content_length = 0 + else: + self._expected_content_length = content_length def _track_content_length(self, length: int, end_stream: bool) -> None: """ diff --git a/tests/test_invalid_content_lengths.py b/tests/test_invalid_content_lengths.py index 3927fb5e2..097404d44 100644 --- a/tests/test_invalid_content_lengths.py +++ b/tests/test_invalid_content_lengths.py @@ -115,6 +115,59 @@ def test_content_length_with_non_digit_value(self, frame_factory, value) -> None ) assert c.data_to_send() == expected_frame.serialize() + @pytest.mark.parametrize("status", ["204", "304"]) + @pytest.mark.parametrize("end_stream_on_headers", [True, False]) + def test_no_content_response_with_content_length(self, frame_factory, status, end_stream_on_headers) -> None: + """Content-Length does not imply a body for a no-content response.""" + c = h2.connection.H2Connection() + c.initiate_connection() + c.send_headers( + 1, + [(":method", "GET"), (":scheme", "https"), (":authority", "example.com"), (":path", "/")], + end_stream=True, + ) + + headers = frame_factory.build_headers_frame( + headers=[(":status", status), ("content-length", "15")], + flags=["END_STREAM"] if end_stream_on_headers else [], + ) + frames = headers.serialize() + if not end_stream_on_headers: + data = frame_factory.build_data_frame(data=b"", flags=["END_STREAM"]) + frames += data.serialize() + + events = c.receive_data(frames) + assert isinstance(events[0], h2.events.ResponseReceived) + assert isinstance(events[-1], h2.events.StreamEnded) + + @pytest.mark.parametrize( + "response_headers", + [ + [(":status", "204")], + [(":status", "204"), ("content-length", "15")], + [(":status", "304"), ("content-length", "15")], + ], + ) + def test_no_content_response_rejects_content(self, frame_factory, response_headers) -> None: + """204 and 304 responses have no content, regardless of Content-Length.""" + c = h2.connection.H2Connection() + c.initiate_connection() + c.send_headers( + 1, + [(":method", "GET"), (":scheme", "https"), (":authority", "example.com"), (":path", "/")], + end_stream=True, + ) + + headers = frame_factory.build_headers_frame( + headers=response_headers, + ) + data = frame_factory.build_data_frame(data=b"x", flags=["END_STREAM"]) + with pytest.raises(h2.exceptions.InvalidBodyLengthError) as exp: + c.receive_data(headers.serialize() + data.serialize()) + + assert exp.value.expected_length == 0 + assert exp.value.actual_length == 1 + @pytest.mark.parametrize( "request_headers", [