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
5 changes: 3 additions & 2 deletions src/httpcore2/httpcore2/_async/http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,10 @@ async def _receive_remote_settings_change(self, event: h2.events.RemoteSettingsC
self._max_streams -= 1

async def _response_closed(self, stream_id: int) -> None:
await self._max_streams_semaphore.release()
async with self._state_lock:
del self._events[stream_id]
if stream_id in self._events:
await self._max_streams_semaphore.release()
del self._events[stream_id]
if self._connection_terminated and not self._events:
await self.aclose()

Expand Down
5 changes: 3 additions & 2 deletions src/httpcore2/httpcore2/_sync/http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,10 @@ def _receive_remote_settings_change(self, event: h2.events.RemoteSettingsChanged
self._max_streams -= 1

def _response_closed(self, stream_id: int) -> None:
self._max_streams_semaphore.release()
with self._state_lock:
del self._events[stream_id]
if stream_id in self._events:
self._max_streams_semaphore.release()
del self._events[stream_id]
if self._connection_terminated and not self._events:
self.close()

Expand Down
31 changes: 31 additions & 0 deletions tests/httpcore2/_async/test_http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,37 @@ async def test_http2_connection_closed() -> None:
assert not conn.is_available()


@pytest.mark.anyio
async def test_http2_response_closed_twice() -> None:
"""
Closing a response for a stream that has already been removed should be
a no-op, rather than raising a `KeyError` that masks the exception which
triggered the cleanup. See https://github.com/encode/httpx/issues/3072
"""
origin = httpcore2.Origin(b"https", b"example.com", 443)
stream = httpcore2.AsyncMockStream(
[
hyperframe.frame.SettingsFrame().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"Hello, world!", flags=["END_STREAM"]).serialize(),
]
)
async with httpcore2.AsyncHTTP2Connection(origin=origin, stream=stream, keepalive_expiry=5.0) as conn:
await conn.request("GET", "https://example.com/")

# The stream was closed when the response completed.
await conn._response_closed(stream_id=1)


@pytest.mark.anyio
async def test_http2_connection_post_request() -> None:
origin = httpcore2.Origin(b"https", b"example.com", 443)
Expand Down
31 changes: 31 additions & 0 deletions tests/httpcore2/_sync/test_http2.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,37 @@ def test_http2_connection_closed() -> None:



def test_http2_response_closed_twice() -> None:
"""
Closing a response for a stream that has already been removed should be
a no-op, rather than raising a `KeyError` that masks the exception which
triggered the cleanup. See https://github.com/encode/httpx/issues/3072
"""
origin = httpcore2.Origin(b"https", b"example.com", 443)
stream = httpcore2.MockStream(
[
hyperframe.frame.SettingsFrame().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"Hello, world!", flags=["END_STREAM"]).serialize(),
]
)
with httpcore2.HTTP2Connection(origin=origin, stream=stream, keepalive_expiry=5.0) as conn:
conn.request("GET", "https://example.com/")

# The stream was closed when the response completed.
conn._response_closed(stream_id=1)


Comment on lines +99 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The test calls conn._response_closed only once on a live stream. The docstring says the test is for the "twice" / already-removed scenario, but the second call that would exercise the fix's if stream_id in self._events: guard is missing. Add a second conn._response_closed(stream_id=1) call so the test actually verifies the no-op behavior on an already-removed stream.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/httpcore2/_sync/test_http2.py, line 99:

<comment>The test calls `conn._response_closed` only once on a live stream. The docstring says the test is for the "twice" / already-removed scenario, but the second call that would exercise the fix's `if stream_id in self._events:` guard is missing. Add a second `conn._response_closed(stream_id=1)` call so the test actually verifies the no-op behavior on an already-removed stream.</comment>

<file context>
@@ -69,6 +69,37 @@ def test_http2_connection_closed() -> None:
+        conn.request("GET", "https://example.com/")
+
+        # The stream was closed when the response completed.
+        conn._response_closed(stream_id=1)
+
+
</file context>
Suggested change
conn._response_closed(stream_id=1)
# Call twice: first close succeeds, second close must be a no-op (no KeyError).
conn._response_closed(stream_id=1)
conn._response_closed(stream_id=1)

@mbeijen mbeijen Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not really a problem and should be ignored


def test_http2_connection_post_request() -> None:
origin = httpcore2.Origin(b"https", b"example.com", 443)
stream = httpcore2.MockStream(
Expand Down
Loading