Repository navigation
Missing client stream reset in h2 connection #943
Copy link
Copy link
Closed
Labels
Description
Discussed in #941
Originally posted by MarkLux August 9, 2024
Background
- Using Http2Connection with a high concurrency stream num (e.g over 400 stream_ids reusing single connection)
- Each stream is waiting for SSE events (assume that there are many server events to receive for each stream)
- Some of the streams cancelled by client (Not ALL)
Phenomenon
- The Cancelled streams will be closed
- But, the Server is still sending SSE events for that stream (because server doesn't receive any signal to indicate the stream is closed, and the connection is still alive because other streams are using)
- The rest streams on this connection, will be blocked in this loop:
# http2.py, class AsyncHTTP2Connection
# ...
async def _receive_stream_event(
self, request: Request, stream_id: int
) -> typing.Union[
h2.events.ResponseReceived, h2.events.DataReceived, h2.events.StreamEnded
]:
while not self._events.get(stream_id):
# REST STREAM ARE BLOCKED HERE
# for each loop, the _receive_events give an events that belongs to other stream (those we closed, but server didn't know, keep sending)
await self._receive_events(request, stream_id)
event = self._events[stream_id].pop(0)
if isinstance(event, h2.events.StreamReset):
raise RemoteProtocolError(event)
return event
we made some experiments and the following logs were observed
Solution
reset the stream when client cancelled the stream, here is a demo (have made experiments to prove it works)
# http2.py, class AsyncHTTP2Connection
async def _response_closed(self, stream_id: int) -> None:
await self._max_streams_semaphore.release()
del self._events[stream_id]
# ADD
stream = self._h2_state._get_stream_by_id(stream_id=stream_id)
if stream and not stream.closed:
# send a RstStream with Cancel Code
self._h2_state.reset_stream(stream_id=stream_id, error_code=8)
# ADD END
async with self._state_lock:
if self._connection_terminated and not self._events:
await self.aclose()
We referred some h2 libraries of other language, and found that the 'client stream reset when cancelled' behavior were both implemented.
e.g.1: go net http2 library
e.g.2: rust h2 library
Reactions are currently unavailable

Activity
stale commentedon Apr 26, 2025
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
Teuniomani commentedon Aug 17, 2026
This is still present on
master, and I have a self-contained reproduction plus acontrol. Posting it here rather than opening a duplicate — the issue looks to have
been closed by the stale bot rather than fixed.
Second observable symptom of the same cause
Besides the blocked-streams case described above, the missing reset also causes
LocalProtocolError: Max outbound streams is 100, 100 openon connections whoseactual concurrency never approaches 100.
_response_closed()releases_max_streams_semaphoreand deletes the events entry,but never tells the h2 state machine the stream is finished. When a response is closed
before END_STREAM — a cancelled task, a timeout, an exception mid-body — h2 keeps the
stream in
open_outbound_streamswhile httpcore hands the slot to the next caller.The two counters then drift apart by exactly one per early close.
Reproduction (no network, no TLS — httpcore's connection is driven over an
in-memory stream backed by a real
h2server, so the accounting measured is h2's own):Output (httpx 0.28.1 / httpcore 1.0.9 / h2 4.3.0, and unchanged against
master):Same code, same server, one difference. httpcore reports all 100 permits free in both
runs; h2 disagrees in the second. Extrapolated, the 101st request on such a connection
raises
Max outbound streams is 100, 100 openwhile httpcore still believes theconnection is idle — which also means an application-level concurrency limit cannot
prevent it, since it bounds the counter that is already correct.
The leak is proportional to the number of early closes, not to load: 100 cancellations
spread over a day have the same effect as 100 in a second. It clears only when the
connection is replaced.
Would a maintainer be willing to reopen? Happy to send a PR that resets the stream in
_response_closed()when it has not already been closed, if that is the direction you'dwant.