diff --git a/src/httpcore2/CHANGELOG.md b/src/httpcore2/CHANGELOG.md index be651418..c0bc36bd 100644 --- a/src/httpcore2/CHANGELOG.md +++ b/src/httpcore2/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Fixed + +* Wait for positive flow-control credit when a peer's `SETTINGS` frame drives a stream's send window negative, instead of letting h2 raise `LocalProtocolError`. ([#935](https://github.com/pydantic/httpx2/pull/935)) + ## 2.0.0 Official first release of `httpcore2`. No changes since `2.0.0b1`. diff --git a/src/httpcore2/httpcore2/_async/http2.py b/src/httpcore2/httpcore2/_async/http2.py index dd9a9062..c5c99909 100644 --- a/src/httpcore2/httpcore2/_async/http2.py +++ b/src/httpcore2/httpcore2/_async/http2.py @@ -465,7 +465,7 @@ async def _wait_for_outgoing_flow(self, request: Request, stream_id: int) -> int local_flow: int = self._h2_state.local_flow_control_window(stream_id) max_frame_size: int = self._h2_state.max_outbound_frame_size flow = min(local_flow, max_frame_size) - while flow == 0: + while flow <= 0: await self._receive_events(request) local_flow = self._h2_state.local_flow_control_window(stream_id) max_frame_size = self._h2_state.max_outbound_frame_size diff --git a/src/httpcore2/httpcore2/_sync/http2.py b/src/httpcore2/httpcore2/_sync/http2.py index f15c35fe..b74c6147 100644 --- a/src/httpcore2/httpcore2/_sync/http2.py +++ b/src/httpcore2/httpcore2/_sync/http2.py @@ -465,7 +465,7 @@ def _wait_for_outgoing_flow(self, request: Request, stream_id: int) -> int: local_flow: int = self._h2_state.local_flow_control_window(stream_id) max_frame_size: int = self._h2_state.max_outbound_frame_size flow = min(local_flow, max_frame_size) - while flow == 0: + while flow <= 0: self._receive_events(request) local_flow = self._h2_state.local_flow_control_window(stream_id) max_frame_size = self._h2_state.max_outbound_frame_size diff --git a/tests/httpcore2/_async/test_http2.py b/tests/httpcore2/_async/test_http2.py index 258ccdc9..5cdf5575 100644 --- a/tests/httpcore2/_async/test_http2.py +++ b/tests/httpcore2/_async/test_http2.py @@ -202,6 +202,44 @@ async def test_http2_connection_with_goaway(): await conn.request("GET", "https://example.com/") +@pytest.mark.anyio +async def test_http2_connection_with_negative_flow_control_window(): + """A negative stream flow-control window must be awaited, not sent into. + + After the 65535-byte window is exhausted, the server reduces INITIAL_WINDOW_SIZE + by 32767, which adjusts the just-exhausted stream window from 0 to -32767. + `_wait_for_outgoing_flow` must park the stream until WINDOW_UPDATE restores + positive credit; otherwise `h2` raises `LocalProtocolError` on the next send_data. + """ + origin = httpcore2.Origin(b"https", b"example.com", 443) + reduce_settings = hyperframe.frame.SettingsFrame(stream_id=0) + reduce_settings.settings = {hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768} + stream = httpcore2.AsyncMockStream( + [ + hyperframe.frame.SettingsFrame(stream_id=0).serialize(), + # This frame reduces INITIAL_WINDOW_SIZE to 32768, which adjusts the just-exhausted stream window to -32767. + reduce_settings.serialize(), + hyperframe.frame.WindowUpdateFrame(stream_id=0, window_increment=100_000).serialize(), + hyperframe.frame.WindowUpdateFrame(stream_id=1, window_increment=100_000).serialize(), + hyperframe.frame.HeadersFrame( + stream_id=1, + data=hpack.Encoder().encode( + [ + (b":status", b"200"), + (b"content-type", b"plain/text"), + ] + ), + flags=["END_HEADERS"], + ).serialize(), + hyperframe.frame.DataFrame(stream_id=1, data=b"response", flags=["END_STREAM"]).serialize(), + ] + ) + async with httpcore2.AsyncHTTP2Connection(origin=origin, stream=stream) as conn: + response = await conn.request("POST", "https://example.com/", content=b"x" * 100_000) + assert response.status == 200 + assert response.content == b"response" + + @pytest.mark.anyio async def test_http2_connection_with_flow_control(): origin = httpcore2.Origin(b"https", b"example.com", 443) diff --git a/tests/httpcore2/_sync/test_http2.py b/tests/httpcore2/_sync/test_http2.py index 3b8239a4..e3d69c36 100644 --- a/tests/httpcore2/_sync/test_http2.py +++ b/tests/httpcore2/_sync/test_http2.py @@ -203,6 +203,44 @@ def test_http2_connection_with_goaway(): +def test_http2_connection_with_negative_flow_control_window(): + """A negative stream flow-control window must be awaited, not sent into. + + After the 65535-byte window is exhausted, the server reduces INITIAL_WINDOW_SIZE + by 32767, which adjusts the just-exhausted stream window from 0 to -32767. + `_wait_for_outgoing_flow` must park the stream until WINDOW_UPDATE restores + positive credit; otherwise `h2` raises `LocalProtocolError` on the next send_data. + """ + origin = httpcore2.Origin(b"https", b"example.com", 443) + reduce_settings = hyperframe.frame.SettingsFrame(stream_id=0) + reduce_settings.settings = {hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768} + stream = httpcore2.MockStream( + [ + hyperframe.frame.SettingsFrame(stream_id=0).serialize(), + # This frame reduces INITIAL_WINDOW_SIZE to 32768, which adjusts the just-exhausted stream window to -32767. + reduce_settings.serialize(), + hyperframe.frame.WindowUpdateFrame(stream_id=0, window_increment=100_000).serialize(), + hyperframe.frame.WindowUpdateFrame(stream_id=1, window_increment=100_000).serialize(), + hyperframe.frame.HeadersFrame( + stream_id=1, + data=hpack.Encoder().encode( + [ + (b":status", b"200"), + (b"content-type", b"plain/text"), + ] + ), + flags=["END_HEADERS"], + ).serialize(), + hyperframe.frame.DataFrame(stream_id=1, data=b"response", flags=["END_STREAM"]).serialize(), + ] + ) + with httpcore2.HTTP2Connection(origin=origin, stream=stream) as conn: + response = conn.request("POST", "https://example.com/", content=b"x" * 100_000) + assert response.status == 200 + assert response.content == b"response" + + + def test_http2_connection_with_flow_control(): origin = httpcore2.Origin(b"https", b"example.com", 443) stream = httpcore2.MockStream(