From 8ec3f90113a4e8ddc1388ccbbc461e741bc85407 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sun, 19 Jul 2026 08:36:32 +0000 Subject: [PATCH 1/2] fix(http2): wait when outbound flow control window is negative h2 can report a negative local flow-control window after DATA has already been accounted. `_wait_for_outgoing_flow` only blocked on `flow == 0`, so a negative window was returned as max_flow and `send_data` raised LocalProtocolError ("Cannot send N bytes, flow control window is -M"). Treat any non-positive flow like zero and wait for WINDOW_UPDATE credit. Fixes #1082 Signed-off-by: Alex Chen --- httpcore/_async/http2.py | 8 ++++-- httpcore/_sync/http2.py | 8 ++++-- tests/_async/test_http2.py | 53 ++++++++++++++++++++++++++++++++++++++ tests/_sync/test_http2.py | 53 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/httpcore/_async/http2.py b/httpcore/_async/http2.py index dbd0beeb4..de2ae5b34 100644 --- a/httpcore/_async/http2.py +++ b/httpcore/_async/http2.py @@ -489,14 +489,18 @@ async def _wait_for_outgoing_flow(self, request: Request, stream_id: int) -> int """ Returns the maximum allowable outgoing flow for a given stream. - If the allowable flow is zero, then waits on the network until + If the allowable flow is zero or negative, then waits on the network until WindowUpdated frames have increased the flow rate. https://tools.ietf.org/html/rfc7540#section-6.9 """ 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: + # h2 may report a *negative* window when prior DATA already overshot the + # remote window (issue #1082). Treat any non-positive flow like zero: + # block on WINDOW_UPDATE instead of returning a negative max_flow that + # later makes `send_data` raise LocalProtocolError. + 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/httpcore/_sync/http2.py b/httpcore/_sync/http2.py index ddcc18900..46629726f 100644 --- a/httpcore/_sync/http2.py +++ b/httpcore/_sync/http2.py @@ -489,14 +489,18 @@ def _wait_for_outgoing_flow(self, request: Request, stream_id: int) -> int: """ Returns the maximum allowable outgoing flow for a given stream. - If the allowable flow is zero, then waits on the network until + If the allowable flow is zero or negative, then waits on the network until WindowUpdated frames have increased the flow rate. https://tools.ietf.org/html/rfc7540#section-6.9 """ 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: + # h2 may report a *negative* window when prior DATA already overshot the + # remote window (issue #1082). Treat any non-positive flow like zero: + # block on WINDOW_UPDATE instead of returning a negative max_flow that + # later makes `send_data` raise LocalProtocolError. + 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/_async/test_http2.py b/tests/_async/test_http2.py index b4ec66488..b3e5ab429 100644 --- a/tests/_async/test_http2.py +++ b/tests/_async/test_http2.py @@ -380,3 +380,56 @@ async def test_http2_remote_max_streams_update(): conn._h2_state.local_settings.max_concurrent_streams, ) i += 1 + + +@pytest.mark.anyio +async def test_http2_wait_for_outgoing_flow_handles_negative_window(): + """Negative local flow must wait like zero (#1082).""" + origin = httpcore.Origin(b"https", b"example.com", 443) + stream = httpcore.AsyncMockStream( + [ + hyperframe.frame.SettingsFrame().serialize(), + # Read while waiting on non-positive flow. + hyperframe.frame.WindowUpdateFrame( + stream_id=0, window_increment=50_000 + ).serialize(), + hyperframe.frame.WindowUpdateFrame( + stream_id=1, window_increment=50_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"ok", flags=["END_STREAM"] + ).serialize(), + ] + ) + async with httpcore.AsyncHTTP2Connection(origin=origin, stream=stream) as conn: + windows = [-49151, -100, 0, 16_384] + + def fake_local_flow_control_window(stream_id): # noqa: ARG001 + return windows.pop(0) if windows else 16_384 + + real_send_body = conn._send_request_body + + async def send_body_with_negative_window(request, stream_id): + conn._h2_state.local_flow_control_window = ( # type: ignore[method-assign] + fake_local_flow_control_window + ) + return await real_send_body(request, stream_id) + + conn._send_request_body = send_body_with_negative_window # type: ignore[method-assign] + response = await conn.request( + "POST", + "https://example.com/", + content=b"x" * 1_000, + ) + assert response.status == 200 + assert response.content == b"ok" diff --git a/tests/_sync/test_http2.py b/tests/_sync/test_http2.py index 695359bd6..b82c9fcf7 100644 --- a/tests/_sync/test_http2.py +++ b/tests/_sync/test_http2.py @@ -380,3 +380,56 @@ def test_http2_remote_max_streams_update(): conn._h2_state.local_settings.max_concurrent_streams, ) i += 1 + + + +def test_http2_wait_for_outgoing_flow_handles_negative_window(): + """Negative local flow must wait like zero (#1082).""" + origin = httpcore.Origin(b"https", b"example.com", 443) + stream = httpcore.MockStream( + [ + hyperframe.frame.SettingsFrame().serialize(), + # Read while waiting on non-positive flow. + hyperframe.frame.WindowUpdateFrame( + stream_id=0, window_increment=50_000 + ).serialize(), + hyperframe.frame.WindowUpdateFrame( + stream_id=1, window_increment=50_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"ok", flags=["END_STREAM"] + ).serialize(), + ] + ) + with httpcore.HTTP2Connection(origin=origin, stream=stream) as conn: + windows = [-49151, -100, 0, 16_384] + + def fake_local_flow_control_window(stream_id): # noqa: ARG001 + return windows.pop(0) if windows else 16_384 + + real_send_body = conn._send_request_body + + def send_body_with_negative_window(request, stream_id): + conn._h2_state.local_flow_control_window = ( # type: ignore[method-assign] + fake_local_flow_control_window + ) + return real_send_body(request, stream_id) + + conn._send_request_body = send_body_with_negative_window # type: ignore[method-assign] + response = conn.request( + "POST", + "https://example.com/", + content=b"x" * 1_000, + ) + assert response.status == 200 + assert response.content == b"ok" From 791d4b08835e48e8e7bc269b82d478ba831c2841 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sun, 19 Jul 2026 08:57:39 +0000 Subject: [PATCH 2/2] test(http2): avoid unused type: ignore in negative-window regression CI mypy reported unused-ignore on method-assign comments in the #1082 regression. Use setattr for the temporary monkeypatches instead. Signed-off-by: Alex Chen --- tests/_async/test_http2.py | 8 +++++--- tests/_sync/test_http2.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/_async/test_http2.py b/tests/_async/test_http2.py index b3e5ab429..d919383e7 100644 --- a/tests/_async/test_http2.py +++ b/tests/_async/test_http2.py @@ -420,12 +420,14 @@ def fake_local_flow_control_window(stream_id): # noqa: ARG001 real_send_body = conn._send_request_body async def send_body_with_negative_window(request, stream_id): - conn._h2_state.local_flow_control_window = ( # type: ignore[method-assign] - fake_local_flow_control_window + setattr( + conn._h2_state, + "local_flow_control_window", + fake_local_flow_control_window, ) return await real_send_body(request, stream_id) - conn._send_request_body = send_body_with_negative_window # type: ignore[method-assign] + setattr(conn, "_send_request_body", send_body_with_negative_window) response = await conn.request( "POST", "https://example.com/", diff --git a/tests/_sync/test_http2.py b/tests/_sync/test_http2.py index b82c9fcf7..3573450cc 100644 --- a/tests/_sync/test_http2.py +++ b/tests/_sync/test_http2.py @@ -420,12 +420,14 @@ def fake_local_flow_control_window(stream_id): # noqa: ARG001 real_send_body = conn._send_request_body def send_body_with_negative_window(request, stream_id): - conn._h2_state.local_flow_control_window = ( # type: ignore[method-assign] - fake_local_flow_control_window + setattr( + conn._h2_state, + "local_flow_control_window", + fake_local_flow_control_window, ) return real_send_body(request, stream_id) - conn._send_request_body = send_body_with_negative_window # type: ignore[method-assign] + setattr(conn, "_send_request_body", send_body_with_negative_window) response = conn.request( "POST", "https://example.com/",