From d336d45203f2b0286cce36d97fca350c9bebeb68 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Thu, 14 May 2026 10:07:33 +0200 Subject: [PATCH 1/4] Fix HTTP/2 send when flow-control window is negative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_wait_for_outgoing_flow` previously waited only while the available flow was exactly zero. Per RFC 7540 ยง6.9.2, when a peer sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE, every existing stream send window is adjusted by the delta and may become negative. With the `while flow == 0` guard, a negative window passed straight through and h2 raised `LocalProtocolError("Cannot send N bytes, flow control window is -M")` on the next send_data call. Switching the guard to `while flow <= 0` keeps the stream parked until WINDOW_UPDATE frames restore positive credit. Refs: https://github.com/encode/httpcore/issues/1082 Refs: https://github.com/encode/httpx/discussions/3601 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 +++ src/httpcore2/httpcore2/_async/http2.py | 2 +- src/httpcore2/httpcore2/_sync/http2.py | 2 +- tests/httpcore2/_async/test_http2.py | 60 +++++++++++++++++++++++++ tests/httpcore2/_sync/test_http2.py | 60 +++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8270f14..02615d4d 100644 --- a/CHANGELOG.md +++ b/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 + +* Fix HTTP/2 send when a peer's `SETTINGS` frame reduces `INITIAL_WINDOW_SIZE` such that a stream's flow-control window becomes negative. Previously `httpcore2` would unblock as soon as the window was non-zero, causing h2 to raise `LocalProtocolError`. (Refs [encode/httpcore#1082](https://github.com/encode/httpcore/issues/1082), [encode/httpx#3601](https://github.com/encode/httpx/discussions/3601).) + ## 2.0.0 Official first release of `httpx2`. No changes since `2.0.0b1`. diff --git a/src/httpcore2/httpcore2/_async/http2.py b/src/httpcore2/httpcore2/_async/http2.py index 75a86078..8d1c9c85 100644 --- a/src/httpcore2/httpcore2/_async/http2.py +++ b/src/httpcore2/httpcore2/_async/http2.py @@ -500,7 +500,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 8d9ed998..c2423583 100644 --- a/src/httpcore2/httpcore2/_sync/http2.py +++ b/src/httpcore2/httpcore2/_sync/http2.py @@ -500,7 +500,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 ab04af4c..1397770e 100644 --- a/tests/httpcore2/_async/test_http2.py +++ b/tests/httpcore2/_async/test_http2.py @@ -223,6 +223,66 @@ 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(): + """ + When a server sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE after a + stream's window has been fully consumed, the stream's flow control window can go + negative. httpcore must wait for WINDOW_UPDATE frames to bring the window back + to a positive value before sending more data, rather than proceeding with the + negative window and letting h2 raise a LocalProtocolError. + See: https://github.com/encode/httpcore/issues/1082 + """ + origin = httpcore2.Origin(b"https", b"example.com", 443) + # The default HTTP/2 stream flow control window is 65535. + # We will send 100,000 bytes, exhausting that window after 65535 bytes. + # At that point the server reduces INITIAL_WINDOW_SIZE from 65535 to 32768, + # which adjusts the stream window from 0 to -32767. + # Without the fix (_wait_for_outgoing_flow uses `while flow == 0` instead of + # `while flow <= 0`), the negative window is returned directly and h2 raises + # LocalProtocolError when send_data is called. + 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(), + # After window exhaustion, server reduces INITIAL_WINDOW_SIZE: + # stream window goes from 0 to 0 + (32768 - 65535) = -32767 + reduce_settings.serialize(), + # Server then provides enough window credit to finish the upload + 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 89aafe10..72e016ea 100644 --- a/tests/httpcore2/_sync/test_http2.py +++ b/tests/httpcore2/_sync/test_http2.py @@ -224,6 +224,66 @@ def test_http2_connection_with_goaway(): +def test_http2_connection_with_negative_flow_control_window(): + """ + When a server sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE after a + stream's window has been fully consumed, the stream's flow control window can go + negative. httpcore must wait for WINDOW_UPDATE frames to bring the window back + to a positive value before sending more data, rather than proceeding with the + negative window and letting h2 raise a LocalProtocolError. + See: https://github.com/encode/httpcore/issues/1082 + """ + origin = httpcore2.Origin(b"https", b"example.com", 443) + # The default HTTP/2 stream flow control window is 65535. + # We will send 100,000 bytes, exhausting that window after 65535 bytes. + # At that point the server reduces INITIAL_WINDOW_SIZE from 65535 to 32768, + # which adjusts the stream window from 0 to -32767. + # Without the fix (_wait_for_outgoing_flow uses `while flow == 0` instead of + # `while flow <= 0`), the negative window is returned directly and h2 raises + # LocalProtocolError when send_data is called. + 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(), + # After window exhaustion, server reduces INITIAL_WINDOW_SIZE: + # stream window goes from 0 to 0 + (32768 - 65535) = -32767 + reduce_settings.serialize(), + # Server then provides enough window credit to finish the upload + 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( From fb763831ff1d4468b8aa4350351c96c4578caa06 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 15 May 2026 07:20:33 -0700 Subject: [PATCH 2/4] Trim verbose comments in negative flow-control test --- tests/httpcore2/_async/test_http2.py | 36 +++++----------------------- tests/httpcore2/_sync/test_http2.py | 36 +++++----------------------- 2 files changed, 12 insertions(+), 60 deletions(-) diff --git a/tests/httpcore2/_async/test_http2.py b/tests/httpcore2/_async/test_http2.py index d9b2bf16..6fe63cc0 100644 --- a/tests/httpcore2/_async/test_http2.py +++ b/tests/httpcore2/_async/test_http2.py @@ -204,39 +204,17 @@ async def test_http2_connection_with_goaway(): @pytest.mark.anyio async def test_http2_connection_with_negative_flow_control_window(): - """ - When a server sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE after a - stream's window has been fully consumed, the stream's flow control window can go - negative. httpcore must wait for WINDOW_UPDATE frames to bring the window back - to a positive value before sending more data, rather than proceeding with the - negative window and letting h2 raise a LocalProtocolError. - See: https://github.com/encode/httpcore/issues/1082 - """ + # After the 65535-byte window is exhausted, server reduces INITIAL_WINDOW_SIZE + # to 32768, driving the stream window to -32767 before WINDOW_UPDATE restores it. origin = httpcore2.Origin(b"https", b"example.com", 443) - # The default HTTP/2 stream flow control window is 65535. - # We will send 100,000 bytes, exhausting that window after 65535 bytes. - # At that point the server reduces INITIAL_WINDOW_SIZE from 65535 to 32768, - # which adjusts the stream window from 0 to -32767. - # Without the fix (_wait_for_outgoing_flow uses `while flow == 0` instead of - # `while flow <= 0`), the negative window is returned directly and h2 raises - # LocalProtocolError when send_data is called. reduce_settings = hyperframe.frame.SettingsFrame(stream_id=0) - reduce_settings.settings = { - hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768 - } + reduce_settings.settings = {hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768} stream = httpcore2.AsyncMockStream( [ hyperframe.frame.SettingsFrame(stream_id=0).serialize(), - # After window exhaustion, server reduces INITIAL_WINDOW_SIZE: - # stream window goes from 0 to 0 + (32768 - 65535) = -32767 reduce_settings.serialize(), - # Server then provides enough window credit to finish the upload - hyperframe.frame.WindowUpdateFrame( - stream_id=0, window_increment=100_000 - ).serialize(), - hyperframe.frame.WindowUpdateFrame( - stream_id=1, window_increment=100_000 - ).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( @@ -247,9 +225,7 @@ async def test_http2_connection_with_negative_flow_control_window(): ), flags=["END_HEADERS"], ).serialize(), - hyperframe.frame.DataFrame( - stream_id=1, data=b"response", flags=["END_STREAM"] - ).serialize(), + hyperframe.frame.DataFrame(stream_id=1, data=b"response", flags=["END_STREAM"]).serialize(), ] ) async with httpcore2.AsyncHTTP2Connection(origin=origin, stream=stream) as conn: diff --git a/tests/httpcore2/_sync/test_http2.py b/tests/httpcore2/_sync/test_http2.py index 83912908..43ebe817 100644 --- a/tests/httpcore2/_sync/test_http2.py +++ b/tests/httpcore2/_sync/test_http2.py @@ -204,39 +204,17 @@ def test_http2_connection_with_goaway(): def test_http2_connection_with_negative_flow_control_window(): - """ - When a server sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE after a - stream's window has been fully consumed, the stream's flow control window can go - negative. httpcore must wait for WINDOW_UPDATE frames to bring the window back - to a positive value before sending more data, rather than proceeding with the - negative window and letting h2 raise a LocalProtocolError. - See: https://github.com/encode/httpcore/issues/1082 - """ + # After the 65535-byte window is exhausted, server reduces INITIAL_WINDOW_SIZE + # to 32768, driving the stream window to -32767 before WINDOW_UPDATE restores it. origin = httpcore2.Origin(b"https", b"example.com", 443) - # The default HTTP/2 stream flow control window is 65535. - # We will send 100,000 bytes, exhausting that window after 65535 bytes. - # At that point the server reduces INITIAL_WINDOW_SIZE from 65535 to 32768, - # which adjusts the stream window from 0 to -32767. - # Without the fix (_wait_for_outgoing_flow uses `while flow == 0` instead of - # `while flow <= 0`), the negative window is returned directly and h2 raises - # LocalProtocolError when send_data is called. reduce_settings = hyperframe.frame.SettingsFrame(stream_id=0) - reduce_settings.settings = { - hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768 - } + reduce_settings.settings = {hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768} stream = httpcore2.MockStream( [ hyperframe.frame.SettingsFrame(stream_id=0).serialize(), - # After window exhaustion, server reduces INITIAL_WINDOW_SIZE: - # stream window goes from 0 to 0 + (32768 - 65535) = -32767 reduce_settings.serialize(), - # Server then provides enough window credit to finish the upload - hyperframe.frame.WindowUpdateFrame( - stream_id=0, window_increment=100_000 - ).serialize(), - hyperframe.frame.WindowUpdateFrame( - stream_id=1, window_increment=100_000 - ).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( @@ -247,9 +225,7 @@ def test_http2_connection_with_negative_flow_control_window(): ), flags=["END_HEADERS"], ).serialize(), - hyperframe.frame.DataFrame( - stream_id=1, data=b"response", flags=["END_STREAM"] - ).serialize(), + hyperframe.frame.DataFrame(stream_id=1, data=b"response", flags=["END_STREAM"]).serialize(), ] ) with httpcore2.HTTP2Connection(origin=origin, stream=stream) as conn: From a0f07fe0d8c13fc690150144f887877caee51359 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 15 May 2026 07:30:06 -0700 Subject: [PATCH 3/4] my nitpick --- tests/httpcore2/_async/test_http2.py | 16 +++++++++------- tests/httpcore2/_sync/test_http2.py | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/tests/httpcore2/_async/test_http2.py b/tests/httpcore2/_async/test_http2.py index 6fe63cc0..5cdf5575 100644 --- a/tests/httpcore2/_async/test_http2.py +++ b/tests/httpcore2/_async/test_http2.py @@ -204,14 +204,20 @@ async def test_http2_connection_with_goaway(): @pytest.mark.anyio async def test_http2_connection_with_negative_flow_control_window(): - # After the 65535-byte window is exhausted, server reduces INITIAL_WINDOW_SIZE - # to 32768, driving the stream window to -32767 before WINDOW_UPDATE restores it. + """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(), @@ -229,11 +235,7 @@ async def test_http2_connection_with_negative_flow_control_window(): ] ) async with httpcore2.AsyncHTTP2Connection(origin=origin, stream=stream) as conn: - response = await conn.request( - "POST", - "https://example.com/", - content=b"x" * 100_000, - ) + response = await conn.request("POST", "https://example.com/", content=b"x" * 100_000) assert response.status == 200 assert response.content == b"response" diff --git a/tests/httpcore2/_sync/test_http2.py b/tests/httpcore2/_sync/test_http2.py index 43ebe817..e3d69c36 100644 --- a/tests/httpcore2/_sync/test_http2.py +++ b/tests/httpcore2/_sync/test_http2.py @@ -204,14 +204,20 @@ def test_http2_connection_with_goaway(): def test_http2_connection_with_negative_flow_control_window(): - # After the 65535-byte window is exhausted, server reduces INITIAL_WINDOW_SIZE - # to 32768, driving the stream window to -32767 before WINDOW_UPDATE restores it. + """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(), @@ -229,11 +235,7 @@ def test_http2_connection_with_negative_flow_control_window(): ] ) with httpcore2.HTTP2Connection(origin=origin, stream=stream) as conn: - response = conn.request( - "POST", - "https://example.com/", - content=b"x" * 100_000, - ) + response = conn.request("POST", "https://example.com/", content=b"x" * 100_000) assert response.status == 200 assert response.content == b"response" From f137c3dcb97ffcbf10e0f583bdd4dcccc52ed470 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 15 May 2026 07:33:38 -0700 Subject: [PATCH 4/4] Move flow-control changelog entry to httpcore2 --- src/httpcore2/CHANGELOG.md | 6 ++++++ src/httpx2/CHANGELOG.md | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) 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/httpx2/CHANGELOG.md b/src/httpx2/CHANGELOG.md index 02615d4d..c8270f14 100644 --- a/src/httpx2/CHANGELOG.md +++ b/src/httpx2/CHANGELOG.md @@ -4,12 +4,6 @@ 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 - -* Fix HTTP/2 send when a peer's `SETTINGS` frame reduces `INITIAL_WINDOW_SIZE` such that a stream's flow-control window becomes negative. Previously `httpcore2` would unblock as soon as the window was non-zero, causing h2 to raise `LocalProtocolError`. (Refs [encode/httpcore#1082](https://github.com/encode/httpcore/issues/1082), [encode/httpx#3601](https://github.com/encode/httpx/discussions/3601).) - ## 2.0.0 Official first release of `httpx2`. No changes since `2.0.0b1`.