diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fe46003..43c8d3d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,8 @@ Release History - Require ``event_hint`` when constructing ``RemoteProtocolError``. This is an API-breaking change. +- Keep the client handshake pending after interim HTTP responses such as + 103 Early Hints, rather than reporting them as connection rejections. 1.3.2 (2025-11-20) ------------------ diff --git a/src/wsproto/events.py b/src/wsproto/events.py index 9474654..2ba6529 100644 --- a/src/wsproto/events.py +++ b/src/wsproto/events.py @@ -105,12 +105,11 @@ class RejectConnection(Event): a response body, the caller should set the ``Content-Length``, ``Content-Type``, and/or ``Transfer-Encoding`` headers as appropriate. - When receiving a ``RejectConnection`` event, the ``has_body`` attribute will - in almost all cases be ``True`` (even if the server set it to ``False``) and - will be followed by at least one ``RejectData`` events, even though the data - itself might be just ``b""``. (The only scenario in which the caller - receives a ``RejectConnection`` with ``has_body == False`` is if the peer - violates sends an informational status code (1xx) other than 101.) + When receiving a ``RejectConnection`` event, the ``has_body`` attribute is + ``True`` (even if the server set it to ``False``) and the event is followed + by at least one ``RejectData`` event, even though the data itself might be + just ``b""``. Interim informational responses (1xx other than 101) are + ignored while waiting for the upgrade or a final HTTP response. The ``has_body`` attribute should only be used when receiving the event. (It has ) is False the headers must include a diff --git a/src/wsproto/handshake.py b/src/wsproto/handshake.py index ab390b8..2350974 100644 --- a/src/wsproto/handshake.py +++ b/src/wsproto/handshake.py @@ -149,15 +149,8 @@ def receive_data(self, data: bytes | None) -> None: if isinstance(event, h11.InformationalResponse): if event.status_code == 101: self._events.append(self._establish_client_connection(event)) - else: - self._events.append( - RejectConnection( - headers=list(event.headers), - status_code=event.status_code, - has_body=False, - ), - ) - self._state = ConnectionState.CLOSED + # Other 1xx responses are interim: keep waiting for the + # upgrade or a final HTTP response (RFC 9110, Section 15.2). elif isinstance(event, h11.Response): self._state = ConnectionState.REJECTING self._events.append( diff --git a/tests/test_client.py b/tests/test_client.py index d1bbdbc..e0295fa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -186,12 +186,52 @@ def test_handshake() -> None: assert events == [AcceptConnection()] -def test_broken_handshake() -> None: - events = _make_handshake( - 102, [(b"connection", b"Upgrade"), (b"upgrade", b"websocket")], - ) - assert isinstance(events[0], RejectConnection) - assert events[0].status_code == 102 +@pytest.mark.parametrize("informational_status", [100, 102, 103, 199]) +@pytest.mark.parametrize("final_status", [101, 403]) +@pytest.mark.parametrize("coalesced", [False, True]) +def test_handshake_informational_responses( + informational_status: int, + final_status: int, + coalesced: bool, +) -> None: + client = WSConnection(CLIENT) + server = h11.Connection(h11.SERVER) + server.receive_data(client.send(Request(host="localhost", target="/"))) + request = cast("h11.Request", server.next_event()) + + # More than one interim response may precede the final response. + interim_data = server.send(h11.InformationalResponse(status_code=informational_status, headers=[])) + interim_data += server.send(h11.InformationalResponse(status_code=103, headers=[])) + if not coalesced: + client.receive_data(interim_data) + assert list(client.events()) == [] + assert client.state is ConnectionState.CONNECTING + + if final_status == 101: + headers = normed_header_dict(request.headers) + response = h11.InformationalResponse( + status_code=101, + headers=[ + (b"connection", b"Upgrade"), + (b"upgrade", b"websocket"), + (b"sec-websocket-accept", generate_accept_token(headers[b"sec-websocket-key"])), + ], + ) + final_data = server.send(response) + else: + final_data = server.send(h11.Response(status_code=403, headers=[(b"content-length", b"0")])) + final_data += server.send(h11.EndOfMessage()) + + client.receive_data((interim_data if coalesced else b"") + final_data) + if final_status == 101: + assert list(client.events()) == [AcceptConnection()] + assert client.state is ConnectionState.OPEN + else: + assert list(client.events()) == [ + RejectConnection(status_code=403, headers=[(b"content-length", b"0")], has_body=True), + RejectData(data=b"", body_finished=True), + ] + assert client.state is ConnectionState.CLOSED def test_handshake_extra_accept_headers() -> None: