Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/httpcore2/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion src/httpcore2/httpcore2/_async/http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/httpcore2/httpcore2/_sync/http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/httpcore2/_async/test_http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions tests/httpcore2/_sync/test_http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down