Skip to content

Add support for HTTP/2 - #13039

Open
Moist-Cat wants to merge 61 commits into
aio-libs:masterfrom
Moist-Cat:master
Open

Add support for HTTP/2#13039
Moist-Cat wants to merge 61 commits into
aio-libs:masterfrom
Moist-Cat:master

Conversation

@Moist-Cat

@Moist-Cat Moist-Cat commented Jul 2, 2026

Copy link
Copy Markdown

What do these changes do?

Add HTTP/2 client support.

Why

Faster I/O bound operations (e.g., many requests to the same host) via multiplexing (handling several streams/requests inside a single connection).

How

  1. Use the environment variable AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=1 to allow h2 negotiation via ALPN during the TLS handshake.
  2. ResponseHandler was substituted by a wrapper that conditionally switches protocols depending on the negotiated protocol.
  3. I forced unconditional connection reuse for HTTP/2 connections since pooling is now unnecessary. This doesn't affect HTTP/1.1 connections. To make this possible, however, I had to use a Semaphore to avoid race conditions.

This means opening many HTTP/1.1 connections in parallel is now slower because it's done sequentially. That said, to know if connections can be pooled or not it's only necessary to wait until the first connection is done. Once it's known whether the host supports HTTP/2 or not, the rest of the requests can be done in parallel so it's possible to mitigate this performance hit substantially.

Backward compatibility

Opt-in via AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS=1.

Testing

%95 coverage, benchmarks (%50 latency reduction for 99 requests, see below), and integration tests against real servers (~100).

Dependencies

hpack

Is it a substantial burden for the maintainers to support this?

Yes.

Related issue number

refs #5999

The implementation is self-contained, the changes to the current codebase are minimal and backwards compatible. That said, I make use of some black magic with __getattr__ to be able to conditionally switch protocols.

Missing features (to the date):

  • Proxies
  • web socket upgrade
  • Streaming
  • CONTINUATION frames for very large headers
  • h2c (cleartext) not supported (optional)
  • Ensure all the high-level configuration/parameters work (or make sense for) with HTTP/2 as well (obsolete)
  • expect100
  • request streaming
  • request payload compression

Moist-Cat and others added 2 commits July 2, 2026 19:38
    This implementation is backwards compatible, functional, but still
incomplete.
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/response.py Fixed
Comment thread aiohttp/http_protocol.py
self._handler: Optional[asyncio.Protocol] = None

# ---- Transport callbacks forwarded to the real handler ----
def connection_made(self, transport: asyncio.BaseTransport) -> None:
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2.py Fixed
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.15152% with 120 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.84%. Comparing base (a5fba5b) to head (8b52301).
⚠️ Report is 14 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
tests/http2/fuzz.py 83.16% 25 Missing and 9 partials ⚠️
aiohttp/http2/connection.py 94.03% 12 Missing and 14 partials ⚠️
aiohttp/http2/synchro.py 61.01% 19 Missing and 4 partials ⚠️
aiohttp/http2/adapter.py 88.07% 10 Missing and 3 partials ⚠️
aiohttp/http2/stream.py 92.96% 6 Missing and 3 partials ⚠️
aiohttp/http_protocol.py 83.33% 3 Missing and 2 partials ⚠️
tests/http2/test_http2.py 99.48% 3 Missing and 2 partials ⚠️
aiohttp/client.py 94.11% 1 Missing and 1 partial ⚠️
aiohttp/client_reqrep.py 84.61% 1 Missing and 1 partial ⚠️
tests/http2/utils.py 97.95% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #13039      +/-   ##
==========================================
- Coverage   99.02%   98.84%   -0.19%     
==========================================
  Files         135      149      +14     
  Lines       50845    53289    +2444     
  Branches     2674     2863     +189     
==========================================
+ Hits        50351    52675    +2324     
- Misses        370      451      +81     
- Partials      124      163      +39     
Flag Coverage Δ
Autobahn 22.12% <25.66%> (+0.15%) ⬆️
CI-GHA 98.74% <95.15%> (-0.18%) ⬇️
OS-Linux 98.53% <95.15%> (-0.17%) ⬇️
OS-Windows 97.20% <95.11%> (-0.11%) ⬇️
OS-macOS 98.03% <95.15%> (-0.15%) ⬇️
Py-3.10 97.98% <95.11%> (-0.15%) ⬇️
Py-3.11 98.20% <95.11%> (-0.16%) ⬇️
Py-3.12 98.28% <95.11%> (-0.16%) ⬇️
Py-3.13 98.26% <95.11%> (-0.17%) ⬇️
Py-3.14 98.29% <95.10%> (-0.16%) ⬇️
Py-3.14t 97.70% <95.10%> (-0.13%) ⬇️
Py-pypy-3.11 97.29% <95.07%> (-0.11%) ⬇️
VM-macos 98.03% <95.15%> (-0.15%) ⬇️
VM-ubuntu 98.53% <95.15%> (-0.17%) ⬇️
VM-windows 97.20% <95.11%> (-0.11%) ⬇️
cython-coverage 81.26% <32.53%> (-1.89%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented Jul 3, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 97 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing Moist-Cat:master (8b52301) with master (a5fba5b)

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Comment thread tests/http2/test_http2.py Fixed
Comment thread docs/conf.py

try:
import sphinxcontrib.spelling # noqa
import sphinxcontrib.spelling
Moist-Cat and others added 2 commits July 4, 2026 21:52
    It was necessary to add a semaphore to ensure the requests connect
sequentially to the hosts and reuse connections when necessary. HTTP/2
uses a single connection per host.
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
Comment thread tests/http2/test_http2_integration.py Fixed
@Moist-Cat

Moist-Cat commented Jul 5, 2026

Copy link
Copy Markdown
Author

I ran tests against remote servers (httpbin.org) to verify HTTP/2 indeed reduces latency.

HTTP/2 Performance Test Results

System Specs:

  • CPU: Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz (8 cores)
  • Memory: 7892016 KB
  • Python: 3.14.2
  • aiohttp: 4.0.0a2.dev0

Test Configuration:

  • Concurrency per batch: 99
  • Number of batches: 30
  • Total requests per version: 2970

Batch Mean Latency (seconds)

Version Mean Std Dev P50 P95 P99
HTTP/1.1 1.3928 0.9116 1.2004 2.9433 4.5932
HTTP/2 0.4821 0.1588 0.4502 0.6966 1.0516

Individual Request Latency Distribution

Version Mean P50 P95 P99
HTTP/1.1 1.3928 0.9819 3.8822 7.1815
HTTP/2 0.4821 0.4517 0.7857 1.3151

Statistical Analysis

  • Welch’s t‑test on batch means:
    t = 5.390, p = 0.000007
  • Cohen’s d: 1.392
  • Assumption: Measurement errors (batch means) are approximately normally distributed (reasonable with 30 batches by the Central Limit Theorem).

A simple bar chart with the means (results vary because they are from a second test):
bar_chart

We lose efficiency in CPU bound tasks (see #13039 (comment)) but I/O bound tasks are significantly faster. This is specially true for batch requests that require multiple TCP connections to the same host.

@Moist-Cat

Copy link
Copy Markdown
Author

I would like to know if the trade-offs (I/O vs CPU) are acceptable before writing the docs.

@Moist-Cat
Moist-Cat marked this pull request as ready for review July 6, 2026 00:29
@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: I ran tests against remote servers (httpbin.org) to verify HTTP/2 indeed reduces latency.

HTTP/1.1 regression not inherent to h2. Caused by global Semaphore(1) wrapping every connector.connect() in _connect_and_send_request. Serializes all connection setup, h1 included — hence the ~8% CodSpeed hit on non-h2 benchmarks. Scope the semaphore to first-connect-per-unknown-host under the flag; h1 parallelism returns.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: I would like to know if the trade-offs (I/O vs CPU) are acceptable before writing the docs.

Bigger blocker than the CPU/IO trade-off. h2 path returns Http2Response, not ClientResponse. Breaks .json(), .text(), cookies, raise_for_status, redirects, middleware. Hold the docs. Resolve response integration, the connector-slot leak, and the semaphore serialization first. The default-path CPU cost is the semaphore — removable, not intrinsic.

@aiolibsbot

aiolibsbot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@Moist-Cat

Moist-Cat commented Jul 6, 2026

Copy link
Copy Markdown
Author

Either inheriting from or using ClientResponse directly appears to be the most architecturally sound approach (even though inheritance in this case constitutes a violation of the Liskov substitution principle), however this class is deeply coupled with HTTP/1.1. For example, the _start method calls protocol.read() from connection. This is incompatible with h2 because the protocol handles many streams, not just the one associated to the response and raise_for_status depends on reason which doesn't exist in h2. A better solution is to create a "doppelganger" class that mimics the public interface, which is precisely what Http2Response is. The public interface is the same so the high-level functionalities that rely on these (e.g., session cookies, redirects) keep work regardless of the underlying protocol. In other words, the API is backwards compatible as far as I tested.

Regarding the Semaphore, I believe simply allowing parallel connections when the flag is not set would be the best approach here since the general solution (i.e., verifying if the host supports h2) requires tracking the hosts in TCPConnector which doesn't seem trivial at glance. Improving performance can be done in another PR after the protocol is integrated and working.

To deal with limit for h2, it's important to decide whether to count streams (current behaviour) or TCP connections (calling _release after every successful connection while keeping the same protocol in the _acquired set).

@Dreamsorcerer Dreamsorcerer added this to the 4.0 milestone Jul 12, 2026
@Moist-Cat
Moist-Cat marked this pull request as ready for review September 4, 2026 19:22
Comment on lines +779 to +781
def _on_read_timeout(self) -> None:
exc = SocketTimeoutError("Timeout on reading data from socket")
self.set_exception(exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security HTTP/2 read timeout leaves requests pending

When an HTTP/2 peer stops sending data, the scheduled read timeout marks the protocol for closure but does not fail active streams' response futures or body readers. The request can therefore remain pending indefinitely instead of receiving SocketTimeoutError. Propagate the timeout through every active stream and terminate the connection consistently so awaiting callers are released.

Artifacts

Focused HTTP/2 timeout repro source

  • Authored async repro creates an HTTP/2 stream over an idle connected transport and runs the scheduled read-timeout failure path; it demonstrates whether the awaiting request is released.

HTTP/2 request state before timeout

  • Executed before-phase capture shows the created HTTP/2 request is pending before any timeout is started, establishing the comparison baseline.

HTTP/2 request state after scheduled timeout

  • Executed after-phase capture shows the callback constructs SocketTimeoutError and closes the protocol state while the stream future and request remain pending, confirming the stranded-request defect.

Repeated HTTP/2 timeout repro output

  • A second executed after-phase run produced the same pending stream and request state after the scheduled timeout, confirming the result is reproducible.

Existing focused HTTP/2 timeout test run

  • Executed focused existing HTTP/2 timeout tests with the repository warning filter disabled; both pass while missing request-future propagation coverage.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The timeout errors triggers at ClientResponse.start so I don't think that function is ever called. I can't reproduce the bug.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That can be true if your test is hitting the outer request timeout, but it does not show that the HTTP/2 socket-read timeout is propagated.

ClientResponse.start() is the waiter here: aiohttp/client_reqrep.py:529 awaits protocol.read_stream(...), which awaits the stream's response_future. The timeout callback at aiohttp/http2/connection.py:779-781 runs independently and calls self.set_exception(exc). However, the HTTP/2 override of set_exception() only sets _should_close and drops the timer; it does not fail the stream's response_future, fail its body_reader, or close the transport.

Therefore:

  • If response headers never arrive, ClientResponse.start() remains pending unless an outer timeout cancels it.
  • If headers arrive but the body stalls, start() has already returned and a body read remains pending.
  • An outer ClientTimeout can produce the timeout observed at start(), masking the missing SocketTimeoutError propagation.

A focused reproduction needs an HTTP/2 server that accepts the request and then sends neither response headers nor body data, with the outer request timeout disabled or set longer than the protocol read timeout. The timeout callback does not need to be called by ClientResponse.start() for this issue to occur.

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.

Important

_on_read_timeout reachable. protocol.start_timeout() runs at client_reqrep.py:1025 (bodyless path) and client_reqrep.py:1551 (after write_eof). Both operate on conn.protocol, which is Http2Protocol here. _read_timeout comes from set_response_params(read_timeout=...).

Repro: ClientTimeout(sock_read=1), then hold stream open server-side without writing.

Two problems when it fires. _cancel_streams(0) kills every stream, not just stalled one. Caller sees RuntimeError("GOAWAY sent or received"), not the SocketTimeoutError you constructed — set_exception discards its argument.

Left non-blocking per your call.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the concrete call path and reproduction. You’re right: start_timeout() is reachable for HTTP/2, and the timeout currently has two observable defects—set_exception() drops the SocketTimeoutError, while _cancel_streams(0) causes the connection-level GOAWAY handling to fail every active stream with RuntimeError. The finding is valid; keeping it non-blocking is reasonable given the requested review priority.

Comment thread aiohttp/http2/stream.py
Comment thread aiohttp/http2/stream.py
if flags & FlagData.PADDED:
# use fuzzy tests to
# verify if it's an error
pad_length = payload[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security An authored runtime harness passed short RSTSTREAM, GOAWAY, and WINDOWUPDATE frames plu...

  • Bug
    • An authored runtime harness passed short RSTSTREAM, GOAWAY, and WINDOWUPDATE frames plus an empty PADDED DATA frame to the real HTTP/2 receiver. Each input raised struct.error or IndexError; the receiver sent RSTSTREAM but left the target stream present, open, and with its response future unresolved. Valid controls closed streams as expected, and non-UTF-8 GOAWAY debug data was accepted. This confirms malformed peer frames can strand active requests.
  • Cause
    • T-Rex reproduced this while running the changed behavior, but it did not return a separate root-cause sentence.
  • Fix
    • Update the changed code so this failing path is handled, then rerun the same T-Rex check to confirm it passes.
Artifacts

Focused HTTP/2 malformed-frame runtime repro script

  • Authored and executed the direct Http2Connection harness for malformed and valid control frames; it isolates the receive paths used for the conclusion.

Malformed HTTP/2 frames leave streams pending

  • Executed short RST_STREAM, GOAWAY, WINDOW_UPDATE, and empty padded DATA inputs; each causes an exception and RST_STREAM while the target stream remains open and unresolved.

Valid HTTP/2 control frames complete their expected handling

  • Executed valid RST_STREAM, non-UTF-8 GOAWAY, and WINDOW_UPDATE controls; streams are closed where expected and non-UTF-8 GOAWAY handling succeeds.

HTTP/2 test suite passes despite malformed-frame gap

  • Executed the complete HTTP/2 test suite with 123 passing tests; existing coverage does not detect the pending-stream behavior.

Relevant HTTP/2 receive-handler source lines

  • Captured numbered source lines for frame dispatch, malformed payload parsing, and exception handling; they identify the root cause.

Artifact and diff validation status

  • Captured artifact status and whitespace validation command output; no production source was modified for this review.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Sending an empty "padded" frame violates the specification.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You're right that an empty PADDED DATA frame is invalid; the review isn't suggesting that it should be accepted. The issue is that peer frames are untrusted, so malformed input still needs to be handled deterministically. Here, payload[0] raises before the padding validation runs; the generic handler then sends RST_STREAM but leaves the existing stream and its unresolved response future in place, stranding the request. The same concern applies to the unchecked fixed-size struct.unpack calls for RST_STREAM, GOAWAY, and WINDOW_UPDATE. These malformed-frame cases should produce the appropriate protocol error or close the affected stream rather than leaving active requests hanging.

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.

Agreed. RFC 9113 §6.1 makes this a connection error. Zero-length payload cannot carry Pad Length octet. Current _protocol_error() response correct.

Adjacent line needs work though. session_inbound_window -= len(data) uses unpadded length. §6.9.1 counts entire frame payload, padding included. Window drifts, transfers stall.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed. data excludes the Pad Length octet and padding, but HTTP/2 flow-control accounting uses the complete DATA frame payload length. Using len(data) undercounts padded frames, causing the receive window to drift and potentially delaying or stalling transfers.

Suggested change
pad_length = payload[0]
self.session_inbound_window -= len(payload)

@Moist-Cat
Moist-Cat requested a review from aiolibsbot September 4, 2026 20:37
@Moist-Cat

Moist-Cat commented Sep 4, 2026

Copy link
Copy Markdown
Author

Pending tasks:

  • Write docs.
  • Add a session-level parameter to opt-in.
  • Mark hpack as an optional dependency.

Comment thread aiohttp/http2/stream.py
Comment thread aiohttp/http2/stream.py
Comment on lines +161 to +176
try:
self._dispatch_frame(frame_type_val, flags, stream_id, payload)
except Exception as exc:
# we really don't want to swallow this exception
import traceback

logger.error("\n".join(traceback.format_exception(exc)))
logger.error(
"Critical error when dispatching frame: exception=%s. (frame_type=%s, flags=%d, stream_id=%d, payload=%s)",
str(exc),
FrameType(frame_type_val),
flags,
stream_id,
payload,
)
self._send_rst_stream(stream_id, ErrorCode.PROTOCOL_ERROR)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Malformed frames leave waiters pending

Short RST_STREAM, GOAWAY, and WINDOW_UPDATE payloads, plus an empty PADDED DATA frame, raise during parsing. The generic exception handler sends an RST_STREAM but does not cancel the existing stream or resolve its response future. The runtime check observed all four inputs leave the stream registered with its response future pending. Validate payload lengths before unpacking and fail the affected stream or connection according to the frame scope.

Artifacts

Focused HTTP2 runtime harness source

  • The authored narrow harness constructs HTTP/2 frames and exercises gzip expansion, pre-header data, malformed frames, and read timeout behavior; it is the executable source used for the observed results.

Focused HTTP2 runtime observed output

  • Executed `/home/user/repo/venv/bin/python trex-artifacts/http2-focused-runtime.py` in `/home/user/repo` with exit code 0; it shows truncation at 262144 bytes, uncapped buffering, unresolved malformed-frame streams, and unresolved timeout waiters.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +779 to +781
def _on_read_timeout(self) -> None:
exc = SocketTimeoutError("Timeout on reading data from socket")
self.set_exception(exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Read timeout does not release streams

_on_read_timeout() invokes the HTTP/2 override of set_exception(), but that override only marks the protocol for closure and drops the timer. It does not fail response futures, body-reader waiters, or the transport. The runtime check observed should_close=True while both an active response future and body read remained pending. Cancel all active and queued streams with SocketTimeoutError and close the connection when this timer fires.

Artifacts

Focused HTTP2 runtime harness source

  • The authored narrow harness constructs HTTP/2 frames and exercises gzip expansion, pre-header data, malformed frames, and read timeout behavior; it is the executable source used for the observed results.

Focused HTTP2 runtime observed output

  • Executed `/home/user/repo/venv/bin/python trex-artifacts/http2-focused-runtime.py` in `/home/user/repo` with exit code 0; it shows truncation at 262144 bytes, uncapped buffering, unresolved malformed-frame streams, and unresolved timeout waiters.

View artifacts

T-Rex Ran code and verified through T-Rex

@Dreamsorcerer

Copy link
Copy Markdown
Member

Concerning benchmarks: currently, it's not viable to measure the performance gains locally because the test server (aiohttp.test_utils.TestServer) doesn't support HTTP/2. Even if it did, local tests shouldn't show significant differences because opening many TCP connections is fast in the local network. To show the benefits of multiplexing, we would have to simulate network latency.

Yeah, sounds like a project for another day.

Sounds like this is almost ready, once the next patch release is done, I'll try and loop back to review.

@Dreamsorcerer

Copy link
Copy Markdown
Member

Oh, and the fuzzers have been merged, so edit or add fuzzers there. The CI part will come once oss-fuzz update their end.

@Moist-Cat

Copy link
Copy Markdown
Author

Oh, and the fuzzers have been merged, so edit or add fuzzers there. The CI part will come once oss-fuzz update their end.

Looks simple enough. I will try to adapt the tests.

Comment on lines +143 to +147
if len(self._frame_buffer) < FRAME_HEADER_LENGTH + length:
break # incomplete frame; wait for more data

payload = bytes(
self._frame_buffer[FRAME_HEADER_LENGTH : FRAME_HEADER_LENGTH + length]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Inbound frame size is unchecked

data_received() trusts the peer-controlled 24-bit length until that many bytes have accumulated in _frame_buffer, then creates a second payload-sized bytes copy. The receiver accepted and dispatched a 131,072-byte DATA frame while the local MAX_FRAME_SIZE was 16,384 bytes. A peer can repeatedly send frames up to the wire-format limit and cause avoidable memory and CPU exhaustion. Reject lengths above local_settings[Setting.MAX_FRAME_SIZE] immediately after parsing the frame header, before retaining the declared payload.

Artifacts

Focused HTTP/2 oversized-frame runtime harness source

  • Authored harness loads the repository parser and sends an oversized DATA frame in network-sized chunks, with a guarded comparison showing the required early rejection.

Current parser accepts and copies an oversized HTTP/2 frame

  • Executed current-code capture shows a 131,072-byte peer frame accumulating beyond the 16,384-byte limit and being dispatched as a copied bytes payload, proving the issue.

Guarded comparison rejects the oversized HTTP/2 frame at its header

  • Executed test-only guarded comparison rejects the same 131,072-byte declaration while retaining no frame bytes, showing the expected enforcement point.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/http2/stream.py
Comment on lines +150 to +177
if self.inbound_window < self._inbound_window_initial // 2:
increment = self._inbound_window_initial - self.inbound_window
self.inbound_window = self._inbound_window_initial
self.conn._send_window_update(self.stream_id, increment)

def receive_data(self, data: bytes, end_stream: bool, limit: int = 0) -> None:
"""Process incoming DATA frame payload."""
self.inbound_window -= len(data)
# the second time we have to pass b"" to the decompressor
# to it will end up reading two times `limit`
limit = (limit or MAX_DECOMPRESS_SIZE) // 2

# --- stream-level flow control refill ---
self.maybe_reset_window()

if not self._headers_received:
# Buffer until we know the content-encoding.
if len(self._pending_data) + len(data) > limit:
msg = "Received too much data before headers."
logging.warning(msg)
self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)
return
self._pending_data.extend(data)
else:
# Feed data to the decompressor or directly to the reader.
if self.decompressor is not None:
try:
self.decompressor.feed_data(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Unread streams replenish credit

receive_data() restores the stream receive window before placing DATA into body_reader, while HTTP/2 pause_reading() is a no-op. Once an application stops consuming a response, the peer continues receiving WINDOW_UPDATE credit despite the reader exceeding its high-water mark. The executed stress case buffered 2 MiB while the configured high-water mark was 131,070 bytes. Tie stream WINDOW_UPDATE messages to application consumption, or withhold credit while the reader is over its high-water mark.

Artifacts

Authored harness for unread HTTP/2 response buffering

  • Loads and executes the repository's production HTTP/2 Stream implementation with local dependency stubs, then sends headers and unread DATA frames; it demonstrates the missing effective backpressure.

Baseline unread HTTP/2 response buffer run

  • Executed two 32 KiB DATA frames without consuming the body; 64 KiB remains below the 131,070-byte high-water mark while one stream credit update is already sent.

Stress unread HTTP/2 response buffer run

  • Executed 64 unread 32 KiB DATA frames; the buffer reaches 2 MiB, pause is requested 61 times without pausing, and stream credit is replenished for all received data — confirming unbounded growth.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +366 to +377
last_stream_id, error_code = struct.unpack("!I I", payload[:8])
extra = payload[8:]
self._last_stream_id = last_stream_id
self._error_code = error_code
logger.info(
"GOAWAY received: last_stream=%d, error=%d, extra=%s",
last_stream_id,
error_code,
extra.decode(errors="replace"),
)

self._cancel_streams(last_stream_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Short GOAWAY strands waiters

A seven-byte GOAWAY raises while unpacking its required eight-byte prefix. The generic receiver recovery then sends RST_STREAM for GOAWAY's connection-level stream ID 0, which does not close active streams or resolve their response futures. The receiver harness left stream 1 registered with its response waiter pending. Validate GOAWAY's payload length and close the connection while failing all active and queued streams when a malformed connection-level frame arrives.

Artifacts

HTTP/2 malformed-frame active-waiter receiver harness

  • Creates a real HTTP/2 protocol and active stream, injects each requested malformed wire frame, and records waiter, stream, and transport state; it directly exercises the receiver.

Active waiters before malformed HTTP/2 frame delivery

  • Captured baseline harness execution with each active response waiter pending before any malformed frame is delivered; it establishes the comparison state.

Active waiters after malformed HTTP/2 frame delivery

  • Captured receiver execution after short RST_STREAM, short GOAWAY, short WINDOW_UPDATE, and empty PADDED DATA delivery; it shows only short GOAWAY leaves its active waiter pending.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/http2/connection.py Outdated
Comment on lines +787 to +792
def _on_read_timeout(self) -> None:
exc = SocketTimeoutError("Timeout on reading data from socket")
self.set_exception(exc)
# cancel all
if self._connection is not None:
self._connection._cancel_streams(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Socket timeout is masked

_on_read_timeout() creates SocketTimeoutError, but set_exception() only marks the protocol for closure. _cancel_streams(0) subsequently fails every active stream with RuntimeError("GOAWAY sent or received"). The executed harness observed both pending response and body waiters receive that RuntimeError instead of the socket timeout. Pass the constructed SocketTimeoutError through the stream cancellation path.

Artifacts

Focused HTTP/2 timeout waiter harness source

  • Authored asyncio harness creates a real HTTP/2 protocol and active response/body waiters, then compares the timeout setup with the production callback; it is the executable reproduction.

Before capture with active HTTP/2 waiters and constructed socket timeout

  • Executed `python trex-artifacts/http2-timeout-waiter-harness.py --mode before` in `/home/user/repo`; it shows both waiters remain active when the socket-timeout exception is constructed.

After capture showing production timeout callback masks socket timeout

  • Executed `python trex-artifacts/http2-timeout-waiter-harness.py --mode after` in `/home/user/repo`; it shows both active waiters receive GOAWAY RuntimeError after the real callback, verifying the masking bug.

Runtime dependency installation log

  • Executed `python -m pip install -r requirements/runtime-deps.txt` in `/home/user/repo`; it shows the harness runtime dependencies were available.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/client.py
except BaseException:
# backwards compatibility
if alpn_protocol == "h2":
stream = await conn.protocol.create_stream() # type: ignore[attr-defined]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stream snapshots stale decompression

The HTTP/2 stream is created before request response parameters are applied, so it snapshots the protocol's previous auto_decompress=True value. The frame-level execution requested auto_decompress=False, but the stream still decompressed a 76-byte gzip response into its 2,115-byte raw body. Apply response parameters before creating the stream, or pass the request-specific decompression setting directly to it.

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/http2/stream.py
Comment on lines +177 to +190
self.decompressor.feed_data(data)
more = self.decompressor.feed_data(b"")
if more is True:
msg = "Overflow detected when decompressing data"
raise ContentEncodingError(msg)
except ContentEncodingError as exc:
self.body_reader.set_exception(exc)
self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)
return
else:
self.body_reader.feed_data(data)

if end_stream:
self.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Final decompression is unresolved

A compressed final DATA frame can leave output pending after DeflateBuffer.feed_data(). The stream finalizes immediately after its limited drain attempt, and feed_eof() requires all decompressor output to have been drained. Ensure final DATA processing drains pending decoder output before EOF and converts any finalization failure into a body-reader exception.

Comment thread aiohttp/http2/stream.py
Comment on lines +177 to +178
self.decompressor.feed_data(data)
more = self.decompressor.feed_data(b"")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Response decompression blocks reads

HTTP/2 response data is decompressed synchronously in the receive callback. A highly compressible peer payload can require substantial CPU and output allocation before the event loop returns to other connections. Apply a practical decompression output limit and avoid processing an unbounded decompression workload synchronously on the receive path.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Dreamsorcerer: > Either inheriting from or using ClientResponse directly appears to be the most architecturally s...

Current diff drops Http2Response entirely. ClientResponse reused via stream_id plus Http2StreamWriter. Reasonable call.

Remaining divergences worth folding into that refactor: :status leaks into resp.headers, compress= silently ignored, expect100 deadlocks, response StreamReader built without request timer. Each flagged inline.

@aiolibsbot

aiolibsbot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Previous review — superseded by a newer review below.

@Moist-Cat

Copy link
Copy Markdown
Author

We are going to skip expect100 as well. The main use-case seems to be asking for permission to send large payloads.

    This substitutes the environment variable used in the draft version.
    I also moved the optional dependency (hpack) to aiohttp[http2]
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Sep 6, 2026
Comment thread aiohttp/client.py
Comment on lines +283 to +287
stream = await conn.protocol.create_stream() # type: ignore[attr-defined]
req.stream_id = stream.stream_id
# release again to clear the protocol from _acquired if required
connector._release(conn._key, conn.protocol, should_close=False)
conn.protocol.set_response_params(**req._response_params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security HTTP/2 streams inherit stale decompression policy

create_stream() snapshots protocol._auto_decompress before this request applies its response parameters. On a shared HTTP/2 connection, requests with opposite auto_decompress values therefore receive the previous request's policy: a caller requesting raw gzip bytes can receive decoded bytes, while a caller requesting decoding can receive encoded bytes. Apply request response parameters before creating the stream, or pass the setting directly to stream construction instead of retaining request-scoped state on the shared protocol.

Artifacts

HTTP/2 gzip request policy harness

  • Authored request-ordering harness that recreates the HTTP/2 client creation order and compares returned gzip bodies with each request's configured policy.

Current HTTP/2 gzip policy leakage result

  • Executed policy harness using current create-stream-before-set-parameters ordering; it shows bodies use stale or another request's auto-decompression policy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/http2/stream.py
Comment on lines +166 to +190
self.maybe_reset_window()

if not self._headers_received:
# Buffer until we know the content-encoding.
if len(self._pending_data) + len(data) > limit:
msg = "Received too much data before headers."
logging.warning(msg)
self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)
return
self._pending_data.extend(data)
else:
# Feed data to the decompressor or directly to the reader.
if self.decompressor is not None:
try:
self.decompressor.feed_data(data)
more = self.decompressor.feed_data(b"")
if more is True:
msg = "Overflow detected when decompressing data"
raise ContentEncodingError(msg)
except ContentEncodingError as exc:
self.body_reader.set_exception(exc)
self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)
return
else:
self.body_reader.feed_data(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Unread streams replenish flow-control credit

The stream restores its receive window before delivering DATA to body_reader, without considering reader occupancy. Because HTTP/2 pause_reading() is a no-op, a peer can continue sending an application-unread response beyond the reader high-water mark. Tie WINDOW_UPDATE credit to application consumption, or withhold credit while the reader is over its bounded high-water mark.

Artifacts

Focused HTTP/2 root recheck harness

  • Authored and executed harness that injects HTTP/2 frames and stream data through the current connection and stream implementations, exercising all nine claimed paths.

Current HTTP/2 root recheck runtime results

  • Executed current-code frame and stream harness showing buffer growth, policy staleness, malformed-frame waiter behavior, synchronous decompression, timeout cancellation, pre-header cap, final gzip output, and oversize rejection.

Focused HTTP/2 root recheck harness

  • Authored and executed harness that injects HTTP/2 frames and stream data through the current connection and stream implementations, exercising all nine claimed paths.

Current HTTP/2 root recheck runtime results

  • Executed current-code frame and stream harness showing buffer growth, policy staleness, malformed-frame waiter behavior, synchronous decompression, timeout cancellation, pre-header cap, final gzip output, and oversize rejection.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +383 to +394
last_stream_id, error_code = struct.unpack("!I I", payload[:8])
extra = payload[8:]
self._last_stream_id = last_stream_id
self._error_code = error_code
logger.info(
"GOAWAY received: last_stream=%d, error=%d, extra=%s",
last_stream_id,
error_code,
extra.decode(errors="replace"),
)

self._cancel_streams(last_stream_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Malformed GOAWAY leaves active requests pending

A short GOAWAY payload reaches struct.unpack() without validating its required eight-byte prefix. The generic exception path then sends RST_STREAM for GOAWAY's stream ID 0, which does not close active streams or resolve their response futures. Reject malformed connection-level frames as connection errors and fail all active and queued streams rather than emitting RST_STREAM(0).

Artifacts

Focused HTTP/2 root recheck harness

  • Authored and executed harness that injects HTTP/2 frames and stream data through the current connection and stream implementations, exercising all nine claimed paths.

Current HTTP/2 root recheck runtime results

  • Executed current-code frame and stream harness showing buffer growth, policy staleness, malformed-frame waiter behavior, synchronous decompression, timeout cancellation, pre-header cap, final gzip output, and oversize rejection.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread aiohttp/http2/stream.py
Comment on lines +180 to +184
self.decompressor.feed_data(data)
more = self.decompressor.feed_data(b"")
if more is True:
msg = "Overflow detected when decompressing data"
raise ContentEncodingError(msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Response decompression monopolizes reads

DATA processing calls the synchronous decompressor directly with a default expansion limit of 2^31-1 bytes. A highly compressible peer payload can allocate a large decoded body and block unrelated event-loop work before control returns. Enforce a practical expansion budget and process decompression in bounded work units that preserve event-loop responsiveness.

Artifacts

Focused HTTP/2 root recheck harness

  • Authored and executed harness that injects HTTP/2 frames and stream data through the current connection and stream implementations, exercising all nine claimed paths.

Current HTTP/2 root recheck runtime results

  • Executed current-code frame and stream harness showing buffer growth, policy staleness, malformed-frame waiter behavior, synchronous decompression, timeout cancellation, pre-header cap, final gzip output, and oversize rejection.

View artifacts

T-Rex Ran code and verified through T-Rex

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: Pending tasks:

Important

Third item not done. http2 extra added to pyproject.toml, but imports stay unconditional. Chain: connector.py:56http_protocol.py:5-6http2/connection.py:29from hpack import Decoder, Encoder. import aiohttp now fails without hpack. client.py:101 and client_reqrep.py:62 pull in http2.adapter too. Defer those imports into HttpDispatcherProtocol.connection_made() and ClientRequest._create_writer(). tests/conftest.py:41 blocks collection as well.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: We are going to skip expect100 as well. The ...

Reasonable scope call. Two follow-ups. Guard at client.py:280 raises NotImplementedError, outside ClientError hierarchy — existing except aiohttp.ClientError handlers miss it. And docs/http2.rst "Limitations" lists h2c, priorities, push, websockets, but not expect100. Add both.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: > @Dreamsorcerer I would assume that's covered by oss-fuzz. We're in the process of trying to mainta...

fuzzers/ now exists: http_parser.py, http_payload_parser.py, multipart.py, payload_url.py, web_request.py. Port tests/http2/fuzz.py there. Also drops from http2.fuzz import ... with noqa: I900, which only resolves via test-collection path. Deferred, not blocking.

@aiolibsbot

aiolibsbot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

PR Review — Add support for HTTP/2

A substantial, genuinely well-built HTTP/2 core — but the packaging change breaks import aiohttp for everyone, and two h2-path defects (event-loop wedge, connection-wide teardown) are hard failures.

Real progress since the last round, and worth naming specifically:

  • The header-injection hole is closed. send_headers() now calls _safe_header() on every name and value before HPACK encoding (connection.py:608-609) — exactly the fix requested, using the canonical helper rather than a reimplementation.
  • remote_settings now seeds from RFC_DEFAULT_SETTINGS (65535 INITIAL_WINDOW_SIZE), so the client no longer overshoots the server's initial window on the first request.
  • :authority is correct: raw_host plus the non-default port, with raw_path_qs for :path.
  • A changelog fragment exists (CHANGES/13039.feature.rst), plus docs/http2.rst, the client_reference.rst parameter, the hpack intersphinx entry, and a session-level http2_enabled opt-in — the four items on your own pending list are all landed.
  • The AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS env-var opt-in is gone, replaced by an explicit constructor parameter, which is the right API shape.
  • The test suite is serious: frame-level RFC compliance, state-machine edge cases, concurrent-send_data deadlock coverage, and end-to-end ClientSession tests over a fake transport.

Blocking:

  • import aiohttp fails without hpack. hpack is only in the optional http2 extra, but connector.pyhttp_protocol.pyhttp2/connection.pyfrom hpack import Decoder, Encoder runs unconditionally. Every user who upgrades without the extra gets ModuleNotFoundError at import. runtime-deps.in hides this locally; it does not control wheel metadata.
  • A 103 Early Hints response wedges the event loop. ClientResponse.start() re-awaits an already-settled response_future; a done future does not yield, so the 1xx continue path becomes a tight non-suspending loop at 100% CPU.
  • One failed stream kills the whole h2 connection. The finally in _connect_and_send_request calls conn.close() while conn._protocol is still set, closing the shared transport and every concurrent request multiplexed on it.
  • The ALPN probe latch is not gated by the opt-in, so HTTP/1.1 users' first concurrent burst per host now serializes; its _done set is never pruned, retaining a ConnectionKey per host forever.
  • Two _release() calls per h2 request append the same protocol to _conns[key] twice, growing that deque unbounded, and _acquired.add() without _acquired_per_host silently disables limit_per_host.
  • receive_data(data, end_stream, payload_len) binds payload_len to the limit parameter — padding escapes stream-level flow control and the pre-header buffer cap becomes payload_len // 2.
  • The zip-bomb guard is inert: limit is never supplied by the production caller (effective cap 2 GiB), and DeflateBuffer.feed_data's data_available return is misread as an overflow error instead of a drain-again signal, which can also trip feed_eof's assert not chunk on legitimate responses.
  • CONTINUATION frames are dropped while the partial HEADERS block is still HPACK-decoded, desyncing the connection-scoped dynamic table and silently corrupting headers on later, unrelated responses.
  • set_response_params mutates shared protocol state, and create_stream() runs before it — so auto_decompress is applied one request late on every h2 request.
  • The __getattribute__ proxy sits in the transport→protocol hot path for every HTTP/1.1 read; transport.set_protocol() in connection_made removes the whole layer.

Non-blocking but worth doing: synchro.py ships a print()-based main() demo in the wheel and lives under http2/ despite serving the h1 path; four SSL contexts are now built at import time against a tracked import-time budget; read_stream raises a bare KeyError; a user-supplied SSLContext silently disables h2; and both tests in test_decompress.py assert nothing meaningful. Moving the fuzzer into fuzzers/ is recorded as deferred per @Dreamsorcerer and @Moist-Cat, and skipping expect100 is honoured as @Moist-Cat's decision.


✅ Resolved since last review (13)

Previously-flagged issues verified fixed
  • aiohttp/http2/connection.py:561 HTTP/2 path bypasses aiohttp's header-injection guard
  • aiohttp/client.py:261 Double _release() grows connector._conns by one duplicate per h2 request and desyncs _acquired_per_host
  • aiohttp/client.py:289 Failure of one h2 request tears down the shared connection for every concurrent stream
  • aiohttp/http2/settings.py:54 remote_settings seeded with local preferences, not the RFC defaults
  • aiohttp/http2/connection.py:240 DATA padding is not counted against flow control
  • aiohttp/http2/adapter.py:118 expect100 deadlocks: buffered headers are never flushed (missing send_headers() override)
  • aiohttp/http2/connection.py:258 HPACK failures and dropped CONTINUATION frames desync the dynamic table for the whole connection
  • aiohttp/http2/connection.py:548 :path is built from the decoded URL path instead of raw_path_qs
  • aiohttp/http2/adapter.py:41 Pseudo-headers leak into resp.headers, and a missing :status silently becomes 500
  • aiohttp/http2/connection.py:825 set_response_params is per-request but the h2 protocol is shared by all streams
  • aiohttp/http2/connection.py:817 Http2Protocol.set_exception discards the exception; write failures never reach waiting streams
  • aiohttp/http2/connection.py:143 No MAX_FRAME_SIZE validation, and malformed frames dump full tracebacks plus raw payload to the log
  • aiohttp/connector.py:954 The experimental opt-in leaks in both directions

🔴 Blocking

1. `hpack` is declared optional but imported unconditionally — `import aiohttp` breaks without it
aiohttp/http2/connection.py:29

pyproject.toml declares hpack >= 4.2.0 only under the new http2 extra, and docs/http2.rst tells users to pip install aiohttp[http2]. But nothing about the import chain is optional:

  • aiohttp/connector.py:56from .http_protocol import HttpDispatcherProtocol
  • aiohttp/http_protocol.py:5-6from .http2.adapter import _get_version / from .http2.connection import Http2Protocol
  • aiohttp/http2/connection.py:29from hpack import Decoder, Encoder
  • aiohttp/http2/stream.py:6from hpack import HeaderTuple

aiohttp/client.py:101 and aiohttp/client_reqrep.py:62 import .http2.adapter at module scope as well.

Why it matters: import aiohttp reaches connector unconditionally, so every existing user who upgrades without adding the extra gets ModuleNotFoundError: No module named 'hpack' at import. That is a total breakage of the package for the default install, not a degraded feature. The same chain also runs through tests/conftest.py:41, so the entire test suite fails to collect without hpack.

(Adding hpack to requirements/runtime-deps.in masks this locally and in CI, but that file does not control the published wheel metadata — [project.dependencies] in pyproject.toml does, and hpack is not there.)

How to fix: make the h2 code path lazily imported. Move from .http2.connection import Http2Protocol inside HttpDispatcherProtocol.connection_made() (only reached when ALPN says h2), move Http2StreamWriter into ClientRequest._create_writer(), and replace the module-level get_version import in client.py/client_reqrep.py with a check that does not pull in http2.* (see the related finding about get_version). Raise a clear RuntimeError("HTTP/2 support requires the 'hpack' package; install aiohttp[http2]") when http2_enabled=True and the import fails. Either that, or promote hpack to a hard dependency in [project.dependencies] and drop the http2 extra and its doc sentence — but not both as they stand.

from hpack import Decoder, Encoder
2. A 1xx interim response over HTTP/2 spins the event loop forever
aiohttp/client_reqrep.py:527-548

ClientResponse.start() loops until it sees a non-1xx status. On the h2 branch each iteration awaits protocol.read_stream(self.stream_id), which returns self._connection.streams[stream_id].response_future (aiohttp/http2/connection.py:889-894) — a future that resolves exactly once.

Stream.maybe_deliver_response() (aiohttp/http2/stream.py:268-275) only sets the result if not self.response_future.done(), so the first HEADERS frame wins permanently. If that first frame carries 103 Early Hints (or 100 Continue), message.code falls in the 100..199 range and is not 101, so the loop takes the continue path and awaits the same already-completed future.

Why it matters: awaiting a done asyncio.Future does not yield — Future.__await__ only yields when not self.done(). So this becomes a tight while True that never suspends: the entire event loop wedges at 100% CPU, not just the one request. 103 Early Hints is specifically deployed over HTTP/2 by real origins and CDNs, so this is reachable against ordinary production servers, not only hostile ones.

How to fix: give Stream a small queue of delivered messages (or a re-armable future) so successive HEADERS frames each produce a message, have receive_headers() deliver a 1xx without terminating the stream, and have read_stream() pop the next message rather than re-await a settled future.

        with self._timer:
            while True:
                # read response
                try:
                    protocol = self._protocol
                    # conditional branching to pass the stream id
                    # to the protocol
                    assert protocol is not None
                    if get_version(protocol) == "h2":
                        message, payload = await protocol.read_stream(self.stream_id)  # type: ignore[attr-defined]
                    else:
                        message, payload = await protocol.read()
                except HttpProcessingError as exc:
                    raise ClientResponseError(
                        self.request_info,
                        self.history,
                        status=exc.code,
                        message=exc.message,
                        headers=exc.headers,
                    ) from exc

                if message.code < 100 or message.code > 199 or message.code == 101:
3. One failed h2 stream tears down the shared connection and every concurrent request on it
aiohttp/client.py:296-306

In the finally block, conn.close() runs whenever resp is None or resp is not None and not started. conn._protocol is only cleared after resp.start(conn) succeeds (line 297), so on any failure path the protocol is still attached.

Connection.close() (aiohttp/connector.py:253-258) calls self._connector._release(self._key, self._protocol, should_close=True), and _release with should_close=True calls protocol.close()Http2Protocol.close()Http2Connection.close()transport.close().

Why it matters: under HTTP/2 that transport is shared by every multiplexed stream on the host. A single request failing — a bad status decode, a ClientResponseError, an asyncio.CancelledError from a per-request timeout, a stream-level RST — closes the TCP connection and kills all sibling in-flight requests with ConnectionError("Connection lost"). That converts an isolated per-request error into a connection-wide outage, which is precisely the failure mode multiplexing is supposed to avoid.

How to fix: on the h2 path, tear down only the stream: send RST_STREAM(CANCEL) for req.stream_id and clear conn._protocol before the finally runs, so conn.close() becomes a no-op. Reserve transport close for genuine connection-level errors (GOAWAY, transport failure).

        if alpn_protocol == "h2":
            # we still have to null the protocol since we didn't close the connection
            conn._protocol = None

        started = True
    finally:
        if resp is not None and not started:
            resp.close()
            conn.close()
        if resp is None:
            conn.close()

🟡 Important

4. The ALPN probe synchronizer serializes HTTP/1.1 connects too, and never prunes its key sets
aiohttp/client.py:239-251

_connect_and_send_request calls await connector.semaphore.acquire(key) unconditionally — there is no check of connector._http2_enabled. I grepped: _http2_enabled is only consulted in _get_ssl_context (aiohttp/connector.py:1331-1338).

Why it matters — two distinct costs:

  • HTTP/1.1 regression. For a session that never enables h2, the first burst of concurrent requests to a new host now serializes: task 1 holds the key, tasks 2..N block on futures until task 1's connector.connect() returns. That is a latency regression on aiohttp's default path for a feature those users opted out of. The PR description acknowledges this trade-off for h2, but it should not apply when h2 was never negotiated.
  • Unbounded retention. HostProbeSynchronizer._done (aiohttp/http2/synchro.py:52) is a Set[Any] that is only ever added to — release() does self._done.add(key) and nothing removes entries. A long-lived session or connector that touches many hosts (a crawler, a proxy, a fan-out client) retains one ConnectionKey per host for the connector's lifetime, and ConnectionKey holds a reference to the SSL context/fingerprint.

How to fix: gate the acquire/release on connector._http2_enabled so the h1-only path is untouched, and bound _done — either an LRU-capped set or drop _done entirely and clean the key up once the first probe resolves.

    try:
        # only the first connection to a host blocks
        # the rest of the connection requests are done
        # concurrently
        await connector.semaphore.acquire(key)

        conn = await connector.connect(req, traces=req._traces, timeout=req._timeout)

        connector.semaphore.release(key)
5. `payload_len` is passed into the `limit` parameter of `receive_data()`
aiohttp/http2/connection.py:273

Stream.receive_data is declared as receive_data(self, data, end_stream, limit=0, payload_len=0) (aiohttp/http2/stream.py:155-157), but the only production call site passes payload_len positionally as the third argument, which binds it to limit. payload_len is left at its default 0.

Two consequences follow:

  • Padding escapes stream-level flow control. Line 160 computes self.inbound_window -= max(len(data), payload_len); with payload_len == 0 this is just len(data), so the pad-length byte and padding octets are never debited. RFC 9113 §6.1 requires the entire DATA payload to count. The connection-level window in _handle_data_frame does account for it, so the two windows drift apart: the client under-reports stream consumption and eventually stalls waiting for credit the server believes it already granted.
  • The pre-headers buffer limit becomes nonsense. Line 163 computes limit = (limit or MAX_DECOMPRESS_SIZE) // 2, i.e. payload_len // 2. Since len(data) ≈ payload_len, the check len(self._pending_data) + len(data) > limit at line 170 is true for essentially any non-trivial DATA frame that arrives before HEADERS, so such a frame is answered with RST_STREAM(INTERNAL_ERROR) rather than buffered. Your own test_data_frame_with_padding only survives because it uses a 1-byte body.

How to fix: call it as stream.receive_data(data, end_stream, payload_len=payload_len) and decide explicitly what limit should be (see the related zip-bomb finding — it is currently never supplied at all).

        stream.receive_data(data, end_stream, payload_len)
6. Zip-bomb protection is inert: the `limit` is never supplied and `feed_data`'s return value is misread
aiohttp/http2/stream.py:163-188

The module comment says MAX_DECOMPRESS_SIZE "protects the client against zip bombs". It does not, for two independent reasons.

1. The limit is never narrowed. receive_headers takes limit: int = 0 and does limit = limit or MAX_DECOMPRESS_SIZE before constructing DeflateBuffer(..., max_decompress_size=limit). The only production caller (connection.py:296) passes no limit, so every stream gets max_decompress_size = 2**31 - 1 — a 2 GiB cap that is not a defence. The passing test tests/http2/test_decompress.py::test_zip_bomb_protection only works because it calls receive_headers(..., limit=65535) by hand, exercising a path production never takes.

2. data_available is not an error signal. DeflateBuffer.feed_data returns self.decompressor.data_available, documented at aiohttp/http_parser.py:1180-1181 as "Return True if more data is available and this method should be called again with b''". Treating a True from the second call as ContentEncodingError("Overflow detected") conflates "drain me again" with "attack". The HTTP/1.1 parser loops until it returns falsey; this code calls it exactly twice and stops.

Because it stops early, unconsumed output stays inside the decompressor, and DeflateBuffer.feed_eof()'s assert not chunk (http_parser.py:1216-1220) can then fire on a perfectly legitimate large compressed response — an AssertionError escaping into Stream.close(). Your own tests/http2/test_decompress.py::test_deflate_buffer_assertion_on_unflushed_data documents exactly this scenario but contains no assertion, so it neither proves nor guards the behaviour.

How to fix: loop while self.decompressor.feed_data(b""): pass (or mirror HttpPayloadParser's drain loop) instead of treating the second return as an error, and thread a real budget down from read_bufsize/ClientTimeout so max_decompress_size is a meaningful number.

                    self.decompressor.feed_data(data)
                    more = self.decompressor.feed_data(b"")
                    if more is True:
                        msg = "Overflow detected when decompressing data"
                        raise ContentEncodingError(msg)
7. HEADERS is HPACK-decoded without checking END_HEADERS, and CONTINUATION frames are dropped
aiohttp/http2/connection.py:275-296

_handle_headers_frame never inspects FlagHeaders.END_HEADERS — it feeds the payload straight to self.hpack_decoder.decode(payload). Meanwhile _dispatch_frame (lines 216-220) lumps CONTINUATION in with PRIORITY and PUSH_PROMISE and merely logs "%d frame ignored (not implemented)".

Why it matters: HPACK's dynamic table is connection-scoped and order-dependent. When a server splits a header block across HEADERS + CONTINUATION (which it must whenever the block exceeds MAX_FRAME_SIZE — routinely triggered by large set-cookie sets or long link preload lists), this code decodes a truncated block and then silently discards the remainder. The decoder's dynamic table is now out of sync with the encoder's for the rest of the connection's life. Every subsequent, unrelated response on that connection then decodes to wrong header names and values — silently, with no error. That is header corruption across request boundaries, which is far worse than failing the one request.

Relatedly, when hpack_decoder.decode() raises, the handler correctly sends GOAWAY(COMPRESSION_ERROR) — good — but the broad except Exception in data_received (lines 178-196) catches state-machine errors from other frame types and answers with _send_rst_stream(...), which is a stream error. Any failure that touched the shared HPACK decoder must be a connection error.

How to fix: buffer HEADERS payloads until END_HEADERS, splice in CONTINUATION payloads, and refuse any interleaved frame on a different stream while a header block is open (RFC 9113 §6.10 makes that a PROTOCOL_ERROR). Until CONTINUATION is implemented, the honest behaviour is to send GOAWAY(PROTOCOL_ERROR) on a HEADERS without END_HEADERS rather than decode a partial block. The PR description lists CONTINUATION as a known gap, but "unsupported" must not mean "silently corrupts later responses".

    def _handle_headers_frame(self, flags: int, stream_id: int, payload: bytes) -> None:
        if flags & FlagHeaders.PRIORITY:
            payload = payload[5:]

        # Decode headers with HPACK
        try:
            headers = self.hpack_decoder.decode(payload)
8. `set_response_params` stores per-request state on the shared protocol — `auto_decompress` lands one request late
aiohttp/http2/connection.py:858-880

Http2Protocol.set_response_params writes self._read_timeout and self._auto_decompress onto the protocol object, which is shared by every multiplexed stream on the connection.

The ordering in client.py:283-289 makes this concretely wrong, not just theoretically racy:

stream = await conn.protocol.create_stream()   # Stream.__init__ snapshots protocol._auto_decompress
...
conn.protocol.set_response_params(**req._response_params)   # ...and only THEN is it set

Stream.__init__ captures self._auto_decompress = protocol._auto_decompress (aiohttp/http2/stream.py:117) before the call that configures it. So a request issued with auto_decompress=False runs its stream with the previous request's value, and the next request inherits False. Every request gets the prior request's decompression setting.

Why it matters: callers who pass auto_decompress=False (streaming proxies, byte-exact mirrors) silently receive decompressed bodies, and callers who expect decompression silently receive gzip bytes. Both are data corruption from the caller's perspective, with no error raised. read_timeout has the same shared-state problem: the last request to configure the connection sets the read timeout for all concurrent streams.

Also note timer is accepted and dropped entirely (it is neither used nor del'd), so the per-request timer context that HTTP/1.1 uses for read accounting is unwired on the h2 path.

How to fix: move these to per-stream state — pass the ResponseParams into create_stream() and store them on the Stream, rather than mutating the connection-wide protocol.

        del skip_payload, read_until_eof, read_bufsize, timeout_ceil_threshold  # compat
        del max_line_size, max_field_size, max_headers  # HTTP/1.1

        self._read_timeout = read_timeout
        self._auto_decompress = auto_decompress
9. `get_version()` re-queries ALPN from the socket on every request, and asserts on a live condition
aiohttp/http2/adapter.py:20-33

get_version() is called three times per request on the client path — client.py:256, client_reqrep.py:535, and client_reqrep.py:1449 — and each call does transport.get_extra_info("ssl_object") followed by ssl_object.selected_alpn_protocol(), a call into OpenSSL. The negotiated protocol cannot change after the handshake, so this is three redundant socket queries per request on the HTTP/1.1 path as well as the h2 one.

Two further problems in the same function:

  • assert transport is not None guards a runtime, network-dependent condition. BaseProtocol.transport becomes None on connection loss, and Http2Protocol.close() explicitly nulls it (connection.py:790). So a connection that drops between connect() and resp.start() raises AssertionError instead of a ClientError — and under python -O the assert vanishes and you get AttributeError: 'NoneType' object has no attribute 'get_extra_info' from inside _get_version. The same applies to assert conn.protocol.transport is not None at client.py:254.
  • hasattr(protocol, "transport") is dead: every BaseProtocol defines transport, so this only guards against duck-typed mocks.

How to fix: resolve ALPN exactly once, in HttpDispatcherProtocol.connection_made(), and record it on the handler (or simply branch on isinstance(protocol, Http2Protocol) at the call sites — a cheap type check with no socket access). That also removes the http2 import from client.py/client_reqrep.py, which helps with the optional-dependency problem. Replace the asserts with an explicit raise ClientConnectionError(...).

def get_version(protocol: BaseProtocol) -> str:
    # backwards compatibility
    if not hasattr(protocol, "transport"):
        return "http/1.1"
    transport = protocol.transport
    assert transport is not None
    return _get_version(transport)
10. A user-supplied `SSLContext` silently disables HTTP/2 despite `http2_enabled=True`
aiohttp/connector.py:1307-1338

_get_ssl_context returns a caller-provided ssl.SSLContext unchanged (the isinstance(sslcontext, ssl.SSLContext) branches). Only the built-in contexts in _SSL_CONTEXT_MAP get set_alpn_protocols(("http/1.1", "h2")).

Why it matters: passing a custom SSL context is extremely common — pinned CAs, client certificates, corporate trust stores, ssl.create_default_context(cafile=...). Those users can set http2_enabled=True, read docs/http2.rst, and get HTTP/1.1 forever with no warning, no log line, and nothing in the docs explaining why. They will file it as a bug against http2_enabled rather than recognising it as an ALPN configuration issue.

How to fix: when self._http2_enabled and the caller supplied a context, either call sslcontext.set_alpn_protocols(("http/1.1", "h2")) on it (documenting that aiohttp adjusts ALPN on user contexts) or emit a one-time warning naming the cause. At minimum, add a "Limitations" bullet in docs/http2.rst — the current list mentions h2c, priorities, push, and websockets but not this, which is the case users are most likely to hit.

        sslcontext = self._ssl
        if isinstance(sslcontext, ssl.SSLContext):
            return sslcontext
        if sslcontext is not True:
            # not verified or fingerprinted
            return _SSL_CONTEXT_MAP[(False, self._http2_enabled)]
        return _SSL_CONTEXT_MAP[(True, self._http2_enabled)]
11. `read_stream()` raises a bare `KeyError` for a closed or unknown stream
aiohttp/http2/connection.py:889-894

self._connection.streams[stream_id] is an unguarded dict lookup. _close_stream (connection.py:466-478) pops the entry from self.streams, and it is called from _handle_rst_stream_frame, _handle_goaway_frame_cancel_streams, and send_data's END_STREAM path.

Why it matters: if the server RSTs the stream or sends GOAWAY in the window between req._send(conn) and resp.start(conn), ClientResponse.start() raises KeyError: 5. That is not a ClientError, so it escapes every except aiohttp.ClientError block users write, and the message tells them nothing. The except HttpProcessingError wrapper right above it does not catch it either.

How to fix: look up with .get() and raise ClientConnectionError(f"HTTP/2 stream {stream_id} was closed before a response arrived") when it is missing. create_stream() already raises ConnectionError for the shutdown case, so the pattern is established — just apply it here too.

    async def read_stream(
        self, stream_id: int
    ) -> tuple[RawResponseMessage, StreamReader]:
        if self._connection is None:
            raise ConnectionError("Connection is not active")
        return await self._connection.streams[stream_id].response_future
12. Four SSL contexts are now built at import time, and the loop variables leak into module scope
aiohttp/connector.py:960-973

The product() loop replaces two _make_ssl_context() calls with four. Two of those (verified=True) call ssl.create_default_context(), which does blocking disk I/O to load the system CA bundle — the comment three lines below explicitly calls this out as the reason the contexts are built at import time.

Why it matters: tests/test_imports.py::test_import_time enforces a 200-300 ms budget for import aiohttp precisely to catch this kind of creep, and this doubles the most expensive part of module init for a feature most users will not enable. On systems with large CA bundles the extra create_default_context() is not cheap.

Secondary: verified and mask are ordinary module-level names after the loop finishes, so aiohttp.connector.verified and aiohttp.connector.mask are now public-looking module attributes. OPTIONAL_PROTOCOLS = 1 with the comment # enable_http2, enable_http3, enable_http4, ... is speculative generality for protocols that do not exist — the product() machinery buys nothing over two explicit entries and makes the key tuple's meaning non-obvious at the call sites.

How to fix: build the h2-enabled contexts lazily (a small @lru_cached factory keyed on (verified, http2), populated on first use) and drop the loop in favour of two explicit module constants plus the lazy pair. That keeps import cost unchanged for h1-only users and removes the leaked names and the unused generality.

OPTIONAL_PROTOCOLS = 1
_SSL_CONTEXT_MAP = {}
for verified in (True, False):
    for mask in product((True, False), repeat=OPTIONAL_PROTOCOLS):
        _SSL_CONTEXT_MAP[(verified,) + mask] = _make_ssl_context(verified, *mask)

🟢 Suggestions

13. Diagnostic goes to the root logger instead of the module logger
aiohttp/http2/stream.py:169-172

This uses the logging module directly rather than the module-level logger = logging.getLogger("aiohttp.http2.stream") defined at line 16, which every other log call in the file uses.

Operators configure aiohttp's loggers by hierarchy (aiohttp.*), so this record bypasses their filtering and level configuration entirely — and this is the only signal that a stream was reset for exceeding the pre-header buffer. Use logger.warning(...) and include the stream id, since without it the message is not actionable on a multiplexed connection.

                msg = "Received too much data before headers."
                logging.warning(msg)
                self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)
14. `_send_rst_stream` fails the waiter with a bare `Exception(ErrorCode(...))`
aiohttp/http2/connection.py:456-464

When the client resets a stream it calls self._close_stream(stream, Exception(ErrorCode(error_code))). That exception is set on stream.response_future and surfaces to the caller of session.get().

A bare Exception whose only content is repr(ErrorCode.INTERNAL_ERROR) is not catchable by except aiohttp.ClientError and carries no context about which request failed or why. _handle_rst_stream_frame (line 306) at least uses a descriptive RuntimeError; this path should be at least as informative.

Use a ClientError subclass with a message naming the stream id and the error code — e.g. ClientConnectionError(f"HTTP/2 stream {stream_id} reset locally: {ErrorCode(error_code).name}") — so users' existing except ClientError handlers work over h2.

    def _send_rst_stream(self, stream_id: int, error_code: int) -> None:
        stream = self.streams.get(stream_id)
        if stream is not None:
            self._close_stream(stream, Exception(ErrorCode(error_code)))
15. `BaseConnector.semaphore` is a public undocumented attribute, and the name no longer describes it
aiohttp/connector.py:408-413

self.semaphore is added to BaseConnector without a leading underscore, so it becomes part of the connector's public surface — subject to the API-stability expectations AGENTS.md attaches to client.py/connector.py, and to THREAT_MODEL.md §6 ("A public API surface is added or removed in client.py").

The name is also misleading: it is not a semaphore. It is a one-shot per-host probe latch (HostProbeSynchronizer) whose release() permanently unblocks a key. A reader encountering connector.semaphore.acquire(key) in client.py:243 will reasonably assume counting-semaphore semantics.

Rename to something private and descriptive — self._alpn_probe or self._first_connect_gate — and add the THREAT_MODEL.md note that AGENTS.md asks for when the client's public surface changes.

        # Semaphore for HTTP/2 connections
        # avoids duplicate connections to the
        # same host
        self.semaphore = HostProbeSynchronizer()
16. `ValueError` / `NotImplementedError` for unsupported h2 features are not `ClientError` subclasses
aiohttp/client_reqrep.py:1446-1452

Two unsupported-feature guards raise exceptions outside aiohttp's error hierarchy:

  • raise ValueError("Payload compression is not supported over HTTP/2") here, raised from inside _create_writer — i.e. after the connection is established and the request is mid-flight.
  • raise NotImplementedError("expect100 is not supported over HTTP/2") at client.py:280.

Neither is caught by except aiohttp.ClientError, so a user who adds http2_enabled=True to an existing session sees an unrelated exception type escape their error handling for a request that worked before. Raising ValueError this late also means the connection has already been acquired and must be torn down.

Use a ClientError subclass, and detect the compress= conflict at ClientRequest construction rather than at writer creation. (Skipping expect100 itself is the author's call — noted by @Moist-Cat: "We are going to skip expect100 as well" — this is only about the exception type and about listing it under "Limitations" in docs/http2.rst, which currently does not mention it.)

            if self.compress:
                raise ValueError("Payload compression is not supported over HTTP/2")
            return Http2StreamWriter(protocol, self.loop, self)

Checklist

  • Package imports cleanly with declared dependencies — critical #1
  • No unbounded collections or resource leaks — warning #4
  • No hangs, deadlocks, or non-yielding loops — critical #2
  • Errors are isolated to the failing request — critical #3
  • Header injection guard applied on new write paths
  • Decompression bomb protection is effective — warning #6
  • Protocol state is not corrupted across requests — warning #7, warning #8
  • Exceptions raised are within the aiohttp error hierarchy — warning #9, warning #11, suggestion #14
  • No regression on the default HTTP/1.1 path — warning #4, warning #9, warning #12
  • Correct argument binding at call sites — warning #5
  • Feature flag has an observable effect in all configurations — warning #10
  • New behaviour is covered by meaningful assertions
  • Changelog fragment present and correctly signed
  • User-visible API documented under docs/
  • No debug/demo code shipped in the package
  • Public API additions named and scoped appropriately — suggestion #15
  • Logging uses the module logger hierarchy — suggestion #13

To rebase and address feedback, mention me: @aiolibsbot rebase critical (fixes 🔴 only), @aiolibsbot rebase important (fixes 🔴 + 🟡), or @aiolibsbot rebase --fix for all. (A bare @aiolibsbot rebase only rebases onto the base branch.)

ℹ️ Triage summary

3 pre-existing finding(s) on unchanged code suppressed (freeze).


Silent Failure Analysis

🔴 **17. CRITICAL** — catch-all swallows frame-dispatch errors, no future failed
aiohttp/http2/connection.py:195-215

Risk: Every dispatch error (struct.error on a truncated SETTINGS/PING/GOAWAY/WINDOW_UPDATE payload, ProtocolError from a state transition, or any internal bug) is logged and converted into RST_STREAM on the frame's stream id — for connection-level frames that is stream 0, which fails to fail any request future, so awaiting callers hang until the read timeout while the connection is left in an undefined state (and RST_STREAM on stream 0 is itself a connection error per RFC 9113 §6.4).

try:
    self._dispatch_frame(frame_type_val, flags, stream_id, payload)
except Exception as exc:
    logger.error(...)
    self._send_rst_stream(stream_id, ErrorCode.PROTOCOL_ERROR)

Fix: Distinguish connection-level from stream-level failures: send GOAWAY and tear down the connection (failing all stream futures and body readers) when stream_id == 0 or the error is not stream-scoped, and only RST_STREAM for genuine stream-scoped errors.

🔴 **18. CRITICAL** — error path leaves consumers hanging / real exception replaced by generic one
aiohttp/http2/connection.py:236-248

Risk: Streams whose response future is already resolved (headers delivered, body still streaming) get neither body_reader.set_exception() nor feed_eof(), so await resp.read() hangs forever on a dropped connection, and the real exc is discarded in favour of a generic ConnectionError that hides the underlying cause.

def connection_lost(self, exc):
    for stream in list(self.streams.values()):
        if not stream.response_future.done():
            stream.response_future.set_exception(ConnectionError("Connection lost"))
    ...
    self.streams.clear()

Fix: Call stream.cancel(ServerDisconnectedError(...) ) (which fails both the future and the body reader) for every stream, chaining the original exc as __cause__.

🟠 **19. HIGH** — argument misrouted into a limit check (dead/spurious error branch)
aiohttp/http2/stream.py:196-215

Risk: The caller passes payload_len positionally into limit, so payload_len is always 0 (flow-control accounting silently ignores padding) and the pre-headers buffer limit becomes half the current frame size, making len(pending) + len(data) > limit fire for ordinary DATA-before-HEADERS frames and resetting the stream with an opaque Exception(ErrorCode.INTERNAL_ERROR).

# connection.py: stream.receive_data(data, end_stream, payload_len)
def receive_data(self, data, end_stream, limit=0, payload_len=0):
    self.inbound_window -= max(len(data), payload_len)
    limit = (limit or MAX_DECOMPRESS_SIZE) // 2

Fix: Call with keywords (stream.receive_data(data, end_stream, payload_len=payload_len)) and derive the buffer limit from a configured maximum rather than the frame length.

🟠 **20. HIGH** — log-only error, response silently dropped
aiohttp/http2/connection.py:320-332

Risk: A HEADERS frame for an unknown stream is logged and dropped with no RST_STREAM/GOAWAY, so a server that responds on a stream the client believes closed produces no protocol error and the mismatch is invisible outside the logs.

stream = self.streams.get(stream_id)
if stream is None:
    logger.error("Unknown stream_id: %d", stream_id)
else:
    stream.receive_headers(headers, end_stream)

Fix: Send RST_STREAM (STREAM_CLOSED) for stream ids at or below next_stream_id, and a connection-level PROTOCOL_ERROR GOAWAY for ids that were never opened.

🟠 **21. HIGH** — user-configured limits silently discarded
aiohttp/http2/connection.py:845-868

Risk: max_headers/max_field_size/max_line_size are DoS protections that are dropped without warning while DEFAULT_SETTINGS[MAX_HEADER_LIST_SIZE] is advertised as 2**32-1, so a session configured with strict header limits silently gets none over HTTP/2.

del skip_payload, read_until_eof, read_bufsize, timeout_ceil_threshold  # compat
del max_line_size, max_field_size, max_headers  # HTTP/1.1

Fix: Map max_headers/max_field_size onto SETTINGS_MAX_HEADER_LIST_SIZE (and enforce it on decode), or raise/warn when a non-default limit is requested on an h2 connection.

🟡 **22. MEDIUM** — exception argument discarded
aiohttp/http2/connection.py:826-840

Risk: exc_cause is dropped (breaking the causal chain that helpers.set_exception builds), the exception is not stored, and the closed future is never resolved, so anything awaiting protocol.closed or created after this call sees a seemingly healthy protocol.

def set_exception(self, exc, exc_cause=_EXC_SENTINEL):
    self._should_close = True
    self._drop_timeout()
    if self._connection is not None:
        self._connection._cancel_streams(0, exc)

Fix: Record the exception, propagate exc_cause into the stream/body-reader failures, and resolve _closed_future with the error.

🟡 **23. MEDIUM** — silent data discard
aiohttp/http2/connection.py:688-694

Risk: Bytes arriving after close()/connection_lost() nulled _connection are dropped with no log or error, so a torn-down-but-still-fed protocol looks idle instead of broken.

def data_received(self, data):
    if data:
        self._reschedule_timeout()
    if self._connection is not None:
        self._connection.data_received(data)

Fix: Log at debug/warning and close the transport when data arrives with no active connection object.

🟡 **24. MEDIUM** — error handled but connection left in an unusable, reusable state
aiohttp/http2/connection.py:305-315

Risk: After an HPACK failure the decoder's dynamic table is permanently desynchronised, yet the transport is left open (unlike _protocol_error()), so subsequent HEADERS frames decode to garbage on a connection that may still be handed out from the pool.

except Exception as exc:  # too general?
    logger.error(f"HPACK decode error: {exc}")
    self._send_goaway(self._last_peer_stream_id, ErrorCode.COMPRESSION_ERROR)
    return

Fix: Route this through _protocol_error()-style handling: send GOAWAY, fail all streams, and close the transport.

🟡 **25. MEDIUM** — warning emitted on the wrong logger
aiohttp/http2/stream.py:205-210

Risk: logging.warning writes to the root logger instead of the module logger, so this stream-reset warning escapes aiohttp's logging namespace and is invisible to anyone configuring aiohttp.http2.* handlers.

msg = "Received too much data before headers."
logging.warning(msg)
self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)

Fix: Use the module-level logger.warning(msg).

🟡 **26. MEDIUM** — instrumentation silently dropped
aiohttp/http2/adapter.py:88-100

Risk: TraceConfig.on_request_chunk_sent / on_request_headers_sent callbacks are never wired up on HTTP/2 requests, so user tracing silently stops reporting once a connection negotiates h2.

del on_chunk_sent, on_headers_sent  # skipped for now
# client_reqrep._create_writer:
return Http2StreamWriter(protocol, self.loop, self)

Fix: Pass and invoke the trace callbacks in write/_send_headers, or emit a warning when tracing is configured on an h2 connection.

🟡 **27. MEDIUM** — configuration silently ignored
aiohttp/connector.py:1325-1340

Risk: When the user supplies their own SSLContext (or a per-request one), http2_enabled=True is dropped because h2 is never added to that context's ALPN list, so HTTP/2 is silently never used with no diagnostic.

sslcontext = self._ssl
if isinstance(sslcontext, ssl.SSLContext):
    return sslcontext
...
return _SSL_CONTEXT_MAP[(True, self._http2_enabled)]

Fix: Add h2 to the ALPN protocols of a user-provided context when http2_enabled is set, or warn that HTTP/2 cannot be enabled for custom SSL contexts.

🟡 **28. MEDIUM** — silent no-op release / permanently disabled guard
aiohttp/http2/synchro.py:88-105

Risk: Once a key lands in _done it is never removed, so every later acquire() returns immediately and release() becomes a no-op — the ALPN-probe serialisation silently stops working after the first connection to a host (and _done grows unboundedly for the connector's lifetime).

if key not in self._locked:
    # this happens when any request after the first calls `release`
    return
self._locked.remove(key)
self._done.add(key)

Fix: Clear the key from _done when the host's connections are dropped/invalidated (or key the state off the connector's connection pool) so the probe guard is re-armed and the set cannot grow without bound.


Automated review by Kōan (Claude) HEAD=8b52301 22 min 17s

@Moist-Cat

Moist-Cat commented Sep 7, 2026

Copy link
Copy Markdown
Author

Now that users can opt-in via pip install aiohttp[http2], I'm wondering if we should enable HTTP/2 automatically and remove the http2_enabled flag so users don't have to take two steps to use the new protocol.

@Dreamsorcerer

Copy link
Copy Markdown
Member

Now that users can opt-in via pip install aiohttp[http2], I'm wondering if we should enable HTTP/2 automatically and remove the http2_enabled flag so users don't have to take two steps to use the new protocol.

Depends how much is working. If it breaks some functionality, we have no guarantee that they don't have the h2 dependency already installed as part of their project.

@Moist-Cat

Copy link
Copy Markdown
Author

Depends how much is working. If it breaks some functionality, we have no guarantee that they don't have the h2 dependency already installed as part of their project.

I suppose the safest option is to use both the optional dependency and the flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants