Skip to content

Commit 7f4bbb5

Browse files
committed
Fix HTTP/2 send when flow-control window is negative
`_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: #1082 Refs: encode/httpx#3601
1 parent 10a6582 commit 7f4bbb5

4 files changed

Lines changed: 122 additions & 2 deletions

File tree

httpcore/_async/http2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ async def _wait_for_outgoing_flow(self, request: Request, stream_id: int) -> int
496496
local_flow: int = self._h2_state.local_flow_control_window(stream_id)
497497
max_frame_size: int = self._h2_state.max_outbound_frame_size
498498
flow = min(local_flow, max_frame_size)
499-
while flow == 0:
499+
while flow <= 0:
500500
await self._receive_events(request)
501501
local_flow = self._h2_state.local_flow_control_window(stream_id)
502502
max_frame_size = self._h2_state.max_outbound_frame_size

httpcore/_sync/http2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ def _wait_for_outgoing_flow(self, request: Request, stream_id: int) -> int:
496496
local_flow: int = self._h2_state.local_flow_control_window(stream_id)
497497
max_frame_size: int = self._h2_state.max_outbound_frame_size
498498
flow = min(local_flow, max_frame_size)
499-
while flow == 0:
499+
while flow <= 0:
500500
self._receive_events(request)
501501
local_flow = self._h2_state.local_flow_control_window(stream_id)
502502
max_frame_size = self._h2_state.max_outbound_frame_size

tests/_async/test_http2.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,66 @@ async def test_http2_connection_with_goaway():
223223
await conn.request("GET", "https://example.com/")
224224

225225

226+
@pytest.mark.anyio
227+
async def test_http2_connection_with_negative_flow_control_window():
228+
"""
229+
When a server sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE after a
230+
stream's window has been fully consumed, the stream's flow control window can go
231+
negative. httpcore must wait for WINDOW_UPDATE frames to bring the window back
232+
to a positive value before sending more data, rather than proceeding with the
233+
negative window and letting h2 raise a LocalProtocolError.
234+
See: https://github.com/encode/httpcore/issues/1082
235+
"""
236+
origin = httpcore.Origin(b"https", b"example.com", 443)
237+
# The default HTTP/2 stream flow control window is 65535.
238+
# We will send 100,000 bytes, exhausting that window after 65535 bytes.
239+
# At that point the server reduces INITIAL_WINDOW_SIZE from 65535 to 32768,
240+
# which adjusts the stream window from 0 to -32767.
241+
# Without the fix (_wait_for_outgoing_flow uses `while flow == 0` instead of
242+
# `while flow <= 0`), the negative window is returned directly and h2 raises
243+
# LocalProtocolError when send_data is called.
244+
reduce_settings = hyperframe.frame.SettingsFrame(stream_id=0)
245+
reduce_settings.settings = {
246+
hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768
247+
}
248+
stream = httpcore.AsyncMockStream(
249+
[
250+
hyperframe.frame.SettingsFrame(stream_id=0).serialize(),
251+
# After window exhaustion, server reduces INITIAL_WINDOW_SIZE:
252+
# stream window goes from 0 to 0 + (32768 - 65535) = -32767
253+
reduce_settings.serialize(),
254+
# Server then provides enough window credit to finish the upload
255+
hyperframe.frame.WindowUpdateFrame(
256+
stream_id=0, window_increment=100_000
257+
).serialize(),
258+
hyperframe.frame.WindowUpdateFrame(
259+
stream_id=1, window_increment=100_000
260+
).serialize(),
261+
hyperframe.frame.HeadersFrame(
262+
stream_id=1,
263+
data=hpack.Encoder().encode(
264+
[
265+
(b":status", b"200"),
266+
(b"content-type", b"plain/text"),
267+
]
268+
),
269+
flags=["END_HEADERS"],
270+
).serialize(),
271+
hyperframe.frame.DataFrame(
272+
stream_id=1, data=b"response", flags=["END_STREAM"]
273+
).serialize(),
274+
]
275+
)
276+
async with httpcore.AsyncHTTP2Connection(origin=origin, stream=stream) as conn:
277+
response = await conn.request(
278+
"POST",
279+
"https://example.com/",
280+
content=b"x" * 100_000,
281+
)
282+
assert response.status == 200
283+
assert response.content == b"response"
284+
285+
226286
@pytest.mark.anyio
227287
async def test_http2_connection_with_flow_control():
228288
origin = httpcore.Origin(b"https", b"example.com", 443)

tests/_sync/test_http2.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,66 @@ def test_http2_connection_with_goaway():
224224

225225

226226

227+
def test_http2_connection_with_negative_flow_control_window():
228+
"""
229+
When a server sends a SETTINGS frame that reduces INITIAL_WINDOW_SIZE after a
230+
stream's window has been fully consumed, the stream's flow control window can go
231+
negative. httpcore must wait for WINDOW_UPDATE frames to bring the window back
232+
to a positive value before sending more data, rather than proceeding with the
233+
negative window and letting h2 raise a LocalProtocolError.
234+
See: https://github.com/encode/httpcore/issues/1082
235+
"""
236+
origin = httpcore.Origin(b"https", b"example.com", 443)
237+
# The default HTTP/2 stream flow control window is 65535.
238+
# We will send 100,000 bytes, exhausting that window after 65535 bytes.
239+
# At that point the server reduces INITIAL_WINDOW_SIZE from 65535 to 32768,
240+
# which adjusts the stream window from 0 to -32767.
241+
# Without the fix (_wait_for_outgoing_flow uses `while flow == 0` instead of
242+
# `while flow <= 0`), the negative window is returned directly and h2 raises
243+
# LocalProtocolError when send_data is called.
244+
reduce_settings = hyperframe.frame.SettingsFrame(stream_id=0)
245+
reduce_settings.settings = {
246+
hyperframe.frame.SettingsFrame.INITIAL_WINDOW_SIZE: 32768
247+
}
248+
stream = httpcore.MockStream(
249+
[
250+
hyperframe.frame.SettingsFrame(stream_id=0).serialize(),
251+
# After window exhaustion, server reduces INITIAL_WINDOW_SIZE:
252+
# stream window goes from 0 to 0 + (32768 - 65535) = -32767
253+
reduce_settings.serialize(),
254+
# Server then provides enough window credit to finish the upload
255+
hyperframe.frame.WindowUpdateFrame(
256+
stream_id=0, window_increment=100_000
257+
).serialize(),
258+
hyperframe.frame.WindowUpdateFrame(
259+
stream_id=1, window_increment=100_000
260+
).serialize(),
261+
hyperframe.frame.HeadersFrame(
262+
stream_id=1,
263+
data=hpack.Encoder().encode(
264+
[
265+
(b":status", b"200"),
266+
(b"content-type", b"plain/text"),
267+
]
268+
),
269+
flags=["END_HEADERS"],
270+
).serialize(),
271+
hyperframe.frame.DataFrame(
272+
stream_id=1, data=b"response", flags=["END_STREAM"]
273+
).serialize(),
274+
]
275+
)
276+
with httpcore.HTTP2Connection(origin=origin, stream=stream) as conn:
277+
response = conn.request(
278+
"POST",
279+
"https://example.com/",
280+
content=b"x" * 100_000,
281+
)
282+
assert response.status == 200
283+
assert response.content == b"response"
284+
285+
286+
227287
def test_http2_connection_with_flow_control():
228288
origin = httpcore.Origin(b"https", b"example.com", 443)
229289
stream = httpcore.MockStream(

0 commit comments

Comments
 (0)