Skip to content

Add support for HTTP/2 - #13039

Draft
Moist-Cat wants to merge 75 commits into
aio-libs:masterfrom
Moist-Cat:master
Draft

Moist-Cat wants to merge 75 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. Via the http2_enabled flag inside ClientSession.
  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. Solved!

Backward compatibility

Opt-in via http2_enabled.

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.

Missing features (to the date):

  • Proxies
  • Web socket upgrade
  • Response 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
  • Send PING frames (if we connect to many servers in parallel and read the payloads sequentially, the server might close the idle connections).
  • Implement a (good) algorithm/strategy to send WINDOW_UPDATE frames.

Things that should be implemented/changed:

  • Support reusable connections
    This means changing the way we handle all connections so the implementation should be done in a separate PR to avoid regressions.

  • Make HTTP/1.1 a particular case of HTTP/2
    HTTP/1.1 connections can be seen as HTTP/2 connections that only allow a single stream. When we try to fit HTTP/2 connections inside routines that were built for HTTP/1.1 like we did here, the implementation ends up looking a bit awkward. (with "awkward" I mean that we have to add 1-2 additional if statements per class to pass the stream_id to the HTTP/2 connector).

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.53339% with 103 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.97%. Comparing base (fe09949) to head (2f47164).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
aiohttp/http2/connection.py 92.44% 16 Missing and 18 partials ⚠️
aiohttp/http2/synchro.py 61.01% 19 Missing and 4 partials ⚠️
aiohttp/http2/adapter.py 88.18% 10 Missing and 3 partials ⚠️
aiohttp/http2/stream.py 90.90% 7 Missing and 5 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/connector.py 94.11% 2 Missing and 1 partial ⚠️
aiohttp/client.py 92.59% 1 Missing and 1 partial ⚠️
aiohttp/client_reqrep.py 84.61% 1 Missing and 1 partial ⚠️
tests/conftest.py 92.30% 2 Missing ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #13039      +/-   ##
==========================================
- Coverage   99.12%   98.97%   -0.15%     
==========================================
  Files         135      148      +13     
  Lines       53661    55932    +2271     
  Branches     2737     2894     +157     
==========================================
+ Hits        53191    55360    +2169     
- Misses        356      420      +64     
- Partials      114      152      +38     
Flag Coverage Δ
Autobahn 21.83% <26.14%> (+0.17%) ⬆️
CI-GHA 98.90% <95.53%> (-0.15%) ⬇️
OS-Linux 98.60% <95.53%> (-0.14%) ⬇️
OS-Windows 97.41% <95.49%> (-0.09%) ⬇️
OS-macOS 98.22% <95.53%> (-0.13%) ⬇️
Py-3.11 98.17% <95.53%> (-0.13%) ⬇️
Py-3.12 98.26% <95.53%> (-0.12%) ⬇️
Py-3.13 98.25% <95.53%> (-0.13%) ⬇️
Py-3.14 98.31% <95.53%> (-0.13%) ⬇️
Py-3.15 98.30% <95.53%> (-0.13%) ⬇️
Py-3.15t 98.16% <95.53%> (-0.13%) ⬇️
Py-pypy-3.12 96.90% <95.49%> (-0.07%) ⬇️
VM-macos-latest 98.22% <95.53%> (-0.13%) ⬇️
VM-ubuntu-latest 98.60% <95.53%> (-0.14%) ⬇️
VM-windows-latest 97.41% <95.49%> (-0.09%) ⬇️
cython-coverage 82.08% <33.40%> (-1.86%) ⬇️

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

codspeed Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 50 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 102 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing Moist-Cat:master (2f47164) with master (fe09949)

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.
@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

try:
from hpack import Decoder, Encoder
except ImportError:
Comment thread tests/conftest.py
try:
from aiohttp.http2.connection import Http2Connection, Http2Protocol
except ImportError:
http2 = None
Comment thread aiohttp/client.py
fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8",
middlewares: Sequence[ClientMiddlewareType] = (),
ssl_shutdown_timeout: _SENTINEL | None | float = sentinel,
http2_enabled: bool = False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Update the threat model

Adding the public ClientSession(http2_enabled=...) API introduces HTTP/2 framing, multiplexing, flow-control, decompression, and resource-exhaustion risks, but THREAT_MODEL.md contains no HTTP/2 client coverage. The repository requires the threat model to be revised when a public API is added in client.py; this repository requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: Now that users can opt-in via pip install aiohttp[http2], I'm wondering if we should enable `HTTP/...

Keep flag. Default off still load-bearing.

Current h2 path diverges from h1 in ways users would hit silently: response headers skip all h1 validation (CR/LF, invalid names, duplicate Content-Length), read_bufsize ignored, no body backpressure, auto_decompress applied one request late, CONTINUATION frames dropped mid-header-block, compress= raises ValueError.

Also hpack already reaches many environments transitively via h2/httpx. Auto-enable would flip protocols for people who never asked.

Two separate bugs make default-off leaky today, worth fixing either way: dispatch ignores _http2_enabled entirely (http_protocol.py:29), so a user SSLContext advertising h2 selects Http2Protocol — and NameErrors when hpack absent. And the probe latch runs for every request regardless of the flag (client.py:243), serializing the first burst per host for h1 users.

Revisit after those land and the multiplexing lifecycle stabilizes.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: > Depends how much is working. If it breaks some functionality, we have no guarantee that they don't...

Agreed. Both, for now.

Extra flag costs little. Reverting an auto-enabled protocol costs a release.

One gap to close: TCPConnector.__init__ checks HTTP2_ENABLED only when http2_enabled=True. Dispatch itself checks neither (http_protocol.py:29), so hpack-absent plus an h2-advertising SSLContext hits NameError: Encoder. Gate on both flags there.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Dreamsorcerer: > I added some rustic fuzzy tests (i.e., generating random but plausible messages to see if the impl...

Noted — fuzzer relocation left deferred per your call, not re-raised.

One thing to carry across when porting: tests/http2/fuzz.py drives Http2Connection.data_received directly and its default_verification only checks DATA-on-closed-stream. Current oracle would miss the header-corruption case — a HEADERS frame without END_HEADERS gets HPACK-decoded as complete (connection.py:290), desyncing the dynamic table. Worth asserting decoder/encoder table agreement in the oss-fuzz harness.

@aiolibsbot

Copy link
Copy Markdown
Contributor

@Moist-Cat: This is a summary of the changes and a general overview of what's covered in this PR and what's not....

On stream_id threading: conditional read/write works, but two lifecycle issues in that seam.

read_stream looks up streams[stream_id], and _close_stream pops the entry — server RST between _send and start raises bare KeyError at users (connection.py:915).

More serious: _connect_and_send_request releases the protocol twice (client.py:262, 285). _release appends to _conns[key] both times, so the pool accumulates duplicates of one protocol. _cleanup then closes the live connection once the oldest duplicate passes keepalive_timeout — mid-stream, with fresher duplicates still pooled. Use _release_acquired for the second call, and prefer keeping an in-use multiplexed protocol out of the idle pool entirely.

@aiolibsbot

aiolibsbot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

PR Review — Add support for HTTP/2

A genuinely impressive HTTP/2 core with real progress since the last round — but the connection-lifecycle integration with the pool is broken in ways that kill in-flight requests, and the read path has no backpressure at all.

What is clearly better this round, and verified in the tree:

  • import aiohttp no longer breaks without hpack. The import is guarded (connection.py:30-33) with an HTTP2_ENABLED flag, and git grep confirms that is the only hpack import in the package.
  • The 1xx event-loop wedge is fixed. receive_headers now returns early for informational responses without resolving response_future (stream.py:204-209), so ClientResponse.start()'s loop can no longer re-await a settled future.
  • Request-direction header injection stays closed — _safe_header() on every name and value before HPACK encoding (connection.py:629-630).
  • Padding is counted against flow control correctly now that payload_len is a real keyword argument (connection.py:288, stream.py:156-159), and :authority/:path use raw_host/raw_path_qs.
  • The frame-level test suite is substantial and reads well — state-machine edges, concurrent send_data, GOAWAY/RST handling, and end-to-end ClientSession tests over a fake transport.

Blocking:

  • A failed h2 request runs conn.close() with the shared protocol still attached, closing the multiplexed transport and killing every concurrent stream — including when a single expect100 request raises NotImplementedError (client.py:299-304).
  • Two _release() calls per h2 request append the same protocol to _conns[key] twice; _cleanup() then closes the live connection once the oldest duplicate goes stale (~keepalive_timeout), and the deque grows by one entry per request forever (client.py:262-285).
  • No backpressure on response bodies: the stream window is refilled on arrival regardless of consumption, pause_reading() is a no-op, and read_bufsize is discarded — a server can force unbounded client-side buffering (stream.py:136-161).
  • A fragmented HEADERS block is HPACK-decoded as if complete while CONTINUATION frames are dropped, desyncing the connection-scoped dynamic table and silently corrupting headers on later, unrelated responses (connection.py:290-311).
  • aiohttp.request(..., http2_enabled=True) raises TypeError — the kwarg is read with get() instead of pop() and forwarded to _request() (client.py:1656).
  • The ALPN probe latch serializes the first request burst per host for every user, with no http2_enabled gate, and its _done set is never pruned (client.py:238-250).
  • Response headers from the h2 path skip all of the h1 parser's validation (CR/LF in values, invalid names, duplicate singletons), and latin-1 encoding rejects UTF-8 values that HTTP/1.1 accepts (adapter.py:36-75).
  • read_stream raises a bare KeyError when the server resets the stream before the read (connection.py:910-915); set_response_params stores per-request state on the shared protocol and runs after create_stream(), so auto_decompress lands one request late (connection.py:879-900).
  • Peer SETTINGS are unvalidated and _send_frame masks the length to 24 bits, so a hostile MAX_FRAME_SIZE can desync framing (connection.py:443); send_data(..., end_stream=True) truncates to one frame (connection.py:583); the zip-bomb guard is inert at the production default because limit doubles as the per-call decompress cap (stream.py:212-220).

Non-blocking, but worth doing: drop the __getattribute__ proxy via transport.set_protocol() (it removes the h1 hot-path cost, the # HACK in connector.py:1529, and the _handler reach-in the connector test now needs); cache the negotiated protocol instead of re-deriving ALPN per request; delete synchro.py's print() demo and move the module out of http2/ since it serves the h1 path; drop the write-only _closed_streams/closed_event/_error_code and the unexported HttpVersion2; add aiohttp/http2/__init__.py; move the generically named h2 fixtures out of the global conftest; and fix the two decompression tests (one asserts nothing on a false premise, the other is a sync test using an async fixture).

Honouring earlier decisions: expect100 being unsupported is @Moist-Cat's call and is not re-raised; moving the fuzzer into fuzzers/ stays deferred per @Dreamsorcerer ("better to update those fuzzers once merged").


✅ Resolved since last review (5)

Previously-flagged issues verified fixed
  • aiohttp/http2/connection.py:273 payload_len is passed into the limit parameter of receive_data()
  • aiohttp/http2/stream.py:163 Zip-bomb protection is inert: the limit is never supplied and feed_data's return value is misread
  • aiohttp/http2/connection.py:858 set_response_params stores per-request state on the shared protocol — auto_decompress lands one request late
  • aiohttp/http2/connection.py:889 read_stream() raises a bare KeyError for a closed or unknown stream
  • aiohttp/http2/connection.py:456 _send_rst_stream fails the waiter with a bare Exception(ErrorCode(...))

🔴 Blocking

1. One failed h2 request tears down the shared connection for every other in-flight stream
aiohttp/client.py:299-304

On the h2 path conn._protocol is only cleared after resp.start() succeeds (line 296). So whenever anything between create_stream() and start() raises, the finally runs conn.close() with the protocol still attached:

Connection.close() → connector._release(key, proto, should_close=True) → protocol.close() → Http2Connection.close() → transport.close().

That is the shared multiplexed transport. Every other concurrent request on that connection gets connection_lost and has its stream cancelled, because one unrelated stream failed.

Reachable cases, all ordinary:

  • expect100=True raises NotImplementedError at line 281 — inside the try, so resp is None → conn.close(). A single expect100 request kills N healthy concurrent requests. (Not re-litigating the decision to skip expect100 — that is your call per the PR description; the problem is the collateral damage.)
  • resp.start() raising ClientResponseError from HttpProcessingError.
  • create_stream() raising when a GOAWAY arrived.

Fix: in the h2 branch, fail only the stream. Detach the connection before the guarded block (conn._protocol = None right after the release at line 262, since the protocol is deliberately not owned by this Connection anymore) and replace conn.close() with an RST_STREAM on req.stream_id so cleanup is scoped to the one stream.

    finally:
        if resp is not None and not started:
            resp.close()
            conn.close()
        if resp is None:
            conn.close()
2. Double `_release()` duplicates the h2 protocol in `_conns`, and the keepalive cleanup then closes the live connection
aiohttp/client.py:262-285

_release() unconditionally appends to the idle pool (connector.py:869: self._conns[key].append((protocol, monotonic()))). The h2 path calls it twice per request — line 262 and again line 285 — with the same protocol object.

Traced over successive requests: request 1 leaves 2 entries in _conns[key]; request 2's connect() pops one, then appends two more → 3; net +1 duplicate entry per request, forever. Two consequences:

  1. The live connection gets closed mid-flight. _cleanup() (connector.py:486-493) closes any pooled entry whose use_time is older than now - keepalive_timeout, regardless of the other, fresher entries for the same object. After ~15 s of continuous use the oldest duplicates go stale and proto.close() runs on the connection that is actively serving streams → transport.close() → all in-flight requests die. Surviving requests then hit assert transport is not None in get_version() (adapter.py:26) and surface an AssertionError from session.get().
  2. Unbounded growth of _conns[key] for the connector's lifetime.

The second _release() ("release again to clear the protocol from _acquired if required") is trying to undo the _acquired.add() at line 267; use connector._release_acquired(conn._key, conn.protocol) for that, which does not touch _conns. Better still: don't put an actively-multiplexed protocol into the idle pool at all — the pool's invariants (_get reuse, _cleanup expiry) all assume a pooled protocol is idle.

        connector._release(conn._key, conn.protocol, should_close=False)
        # the protocol corresponding to the connection
        # remains (i.e., the count per host is always 1 for h2)
        # This is the number of TCP connections not the number of
        # streams
        connector._acquired.add(conn.protocol)
    try:
        # backwards compatibility
        if alpn_protocol == "h2":
            if req._continue:
                # `expect100` is not inherently incompatible
                # but the implementation does not appear to be
                # straightforward.
                # We have to set the result of the future in
                # the HTTP/2 stream yet we can not close the stream
                # because we have to wait for the second response.
                #
                # Also, it's not really necessary in HTTP/2
                # because the server can simply reset the stream.
                raise NotImplementedError("expect100 is not supported over HTTP/2")
            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)
3. HTTP/2 responses have no backpressure: a server can force unbounded client-side buffering
aiohttp/http2/stream.py:136-161

Three decisions combine to remove every backpressure mechanism from the h2 read path:

  • maybe_reset_window() refills the stream's inbound window to the initial value on every DATA frame, independent of whether the application has consumed anything (line 151-154).
  • body_reader is constructed with low_water = INITIAL_WINDOW_SIZE (1 MiB) explicitly "so pause is never called" (line 112-117).
  • Http2Protocol.pause_reading() is a no-op (connection.py:848-853), so when StreamReader.feed_data does exceed high water and calls pause_reading() (streams.py:301-302), nothing happens.

So a server that streams faster than the consumer reads — or simply never stops — makes the client buffer the entire body in memory. read_bufsize cannot cap it either: set_response_params deletes it (connection.py:896). In HTTP/1.1 this is exactly what pause_reading() prevents.

This is the buffer-a-peer-can-fill class of issue the project has been hardening against (see the recent Bound websocket client buffers a peer can fill change and THREAT_MODEL.md), so it is a remote memory-exhaustion vector rather than just a throughput nit.

Fix: make the window refill conditional on consumption. Keep the reader's real low_water (read_bufsize), and grant WINDOW_UPDATE from the consumer side — i.e. send the increment when the reader drains (the resume_reading() hook you already use for the session window), not on arrival. The in-code comment acknowledges "flow-control is not enforced here"; that is the defect, because the window is the only bound that exists.

        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)
4. A fragmented HEADERS block is HPACK-decoded as if complete, desyncing the dynamic table for every later response
aiohttp/http2/connection.py:290-311

_handle_headers_frame never checks FlagHeaders.END_HEADERS, and CONTINUATION frames are dropped with a warning (line 226-231). When a server splits a header block — legal and common for large blocks (many Set-Cookies, big tokens) — this decodes a truncated HPACK fragment.

HPACK's dynamic table is connection-scoped and stateful. A partial decode either raises (→ GOAWAY, connection dies) or succeeds on the prefix and leaves our table permanently out of step with the server's encoder. From then on, indexed field references resolve to the wrong entries: later, unrelated responses on the same connection get silently wrong headers, including Set-Cookie and auth-bearing fields. Silent cross-stream header confusion is worse than a hard failure.

Not supporting CONTINUATION is a fine scope decision (it is on your missing-features list). Failing safely is not optional though:

  • If HEADERS arrives without END_HEADERS, send GOAWAY (INTERNAL_ERROR, or advertise support later) and do not feed the fragment to hpack_decoder.decode().
  • Same for a received CONTINUATION frame — it can only appear after a non-terminated HEADERS, so treat it as a connection error instead of a logger.warning.

Also note line 231 logs "%d frame ignored" with a FrameType member, which renders as FrameType.CONTINUATION rather than a number.

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

        try:
            headers = self.hpack_decoder.decode(payload)

🟡 Important

5. The ALPN probe latch serializes the first request burst for *all* users, including with http2_enabled=False
aiohttp/client.py:238-250

connector.semaphore.acquire(key) / release(key) wrap connector.connect() for every request, with no check of self._http2_enabled. HostProbeSynchronizer lets only the first task per key proceed and parks the rest until that first connect() returns.

So for plain HTTP/1.1 users who never opt into h2, a burst of N concurrent requests to a fresh host now costs one full serialized DNS+TCP+TLS round trip before the other N-1 even start connecting. That contradicts both "Opt-in via http2_enabled" and "This doesn't affect HTTP/1.1 connections" — the cost is unconditional.

Two more problems in the same block:

  • release(key) is called twice (line 246 inside the try, line 250 in the finally). It happens to be idempotent, so the line 246 call is dead code; drop it.
  • _done (synchro.py:79) is never pruned, so the connector retains a ConnectionKey per host it ever contacted — a slow leak in exactly the fan-out-to-many-hosts workload aiohttp is used for. It also means the latch never re-arms if the connection is later dropped.

Fix: gate the acquire/release on connector._http2_enabled (and clear the key from _done when the host's connections are invalidated).

    key = req.connection_key
    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)
    except asyncio.TimeoutError as exc:
        raise ConnectionTimeoutError(f"Connection timeout to host {req.url}") from exc
    finally:
        connector.semaphore.release(key)
6. `aiohttp.request(..., http2_enabled=True)` always raises TypeError
aiohttp/client.py:1656-1657

kwargs.get("http2_enabled", False) reads the value but leaves it in kwargs, which is then splatted into session._request(method, url, **kwargs) at line 1668. _request (line 519-551) has no http2_enabled parameter and no **kwargs, so the call dies with:

TypeError: _request() got an unexpected keyword argument 'http2_enabled'

This fires on every use of the module-level helper with the new flag, and also when a connector is supplied (the kwarg is forwarded regardless of the branch). The typed overload is inconsistent too: _RequestOptions has no http2_enabled member, so mypy rejects the call outright.

Fix: use kwargs.pop("http2_enabled", False), move it above the if connector is None check, and add the key to _RequestOptions — or drop http2_enabled support from request() entirely and document ClientSession as the only entry point.

            http2_enabled = kwargs.get("http2_enabled", False)
            connector = TCPConnector(force_close=True, http2_enabled=http2_enabled)
7. `read_stream` raises a bare KeyError when the server resets the stream early
aiohttp/http2/connection.py:910-915

self._connection.streams[stream_id] assumes the stream is still registered. But _close_stream() pops it from self.streams (line 499), and that happens on RST_STREAM, on GOAWAY, and on the internal error path.

The window is real: _connect_and_send_request awaits req._send(conn) (which schedules the writer task and yields) before resp.start(conn) reaches read_stream(). A server that rejects the stream promptly — REFUSED_STREAM, rate limiting, auth rejection — gets its RST_STREAM processed in between, so read_stream looks up an already-removed id.

The user then sees KeyError: 3 escape from session.get(), instead of the ClientError subclass aiohttp callers catch. The exception that stream.cancel() carefully set on response_future is lost, since the future is unreachable.

Fix: keep the future (or the terminal exception) reachable after the stream is unregistered — e.g. a small _stream_results: dict[int, Future] retained until read — and raise ClientConnectionError/ServerDisconnectedError when the id is genuinely unknown.

        if self._connection is None:
            raise ConnectionError("Connection is not active")
        return await self._connection.streams[stream_id].response_future
8. Per-request response params are stored on the connection-wide protocol, and applied one request late
aiohttp/http2/connection.py:879-900

set_response_params writes self._read_timeout and self._auto_decompress onto the Http2Protocol, which every concurrent stream shares. Two distinct bugs follow:

Off-by-one. In client.py the order is create_stream() (line 282) → set_response_params() (line 286). Stream.__init__ snapshots protocol._auto_decompress (stream.py:109), so a request passing auto_decompress=False still decompresses, and the next request on that connection inherits the flag. Swapping the two lines fixes the ordering but not the sharing.

Cross-request leakage. With requests A (sock_read=5) and B (a long download) multiplexed, whichever called set_response_params last wins for both. When _read_timeout fires, _on_read_timeout → _cancel_streams(0, exc) cancels every stream on the connection, so one request's idle timeout aborts all the others.

timer is also dropped: Stream.body_reader is built without it (stream.py:112-117), so ClientTimeout.total/sock_read are not enforced during body reads over h2, unlike HTTP/1.1.

Fix: carry these per-request values on the Stream (pass them into create_stream()), keep the protocol stateless, and scope the read timeout to the stream that owns it.

        self._read_timeout = read_timeout
        self._auto_decompress = auto_decompress
9. `limit` is used both as the per-call decompress cap and the total cap, so the zip-bomb guard is inert in production
aiohttp/http2/stream.py:212-220

max_decompress_size=self.limit where limit defaults to MAX_DECOMPRESS_SIZE (2 GiB) and the production caller never overrides it (_create_stream_internal builds Stream(sid, self, loop, protocol) with no limit).

DeflateBuffer.feed_data computes max_length = max(self._max_decompress_size, low_water) (http_parser.py:1205-1207), so each call is allowed to inflate up to 2 GiB in one decompress_sync. data_available then comes back False, the while self.decompressor.feed_data(b"") drain loop in receive_data never iterates, and the total_bytes >= self.limit check never runs until 2 GiB has already been materialized. The HTTP/1.x path passes the read buffer size here instead, which is what makes its incremental check meaningful.

tests/http2/test_decompress.py::test_zip_bomb_protection only passes because it constructs the stream with limit=65535, which coincidentally makes per-call and total caps equal. It therefore validates a configuration the library never produces.

Fix: separate the two limits — pass the read buffer size (or INITIAL_WINDOW_SIZE) as max_decompress_size so decompression stays incremental, and keep MAX_DECOMPRESS_SIZE purely as the cumulative cap compared against body_reader.total_bytes. Then have the test exercise the default.

                self.decompressor = DeflateBuffer(
                    self.body_reader,
                    encoding=encoding,
                    max_decompress_size=self.limit,
                )
10. Frame length is silently masked to 24 bits, and peer SETTINGS values are never range-checked
aiohttp/http2/connection.py:443-454

length = len(payload) & 0x00FFFFFF discards the high bits instead of refusing to send. Any payload ≥ 16 MiB produces a header whose declared length disagrees with the bytes actually written, which desynchronizes the framing layer for the rest of the connection — the peer will parse our payload tail as new frame headers.

That is reachable because nothing validates the peer's SETTINGS (_handle_settings_frame, line 351-378): remote_settings[MAX_FRAME_SIZE] is accepted verbatim and then used directly as the chunk size in send_data (line 553-560). RFC 9113 §6.5.2 requires MAX_FRAME_SIZE outside 16384..16777215 to be a PROTOCOL_ERROR; INITIAL_WINDOW_SIZE > 2**31-1 to be a FLOW_CONTROL_ERROR; ENABLE_PUSH other than 0/1 to be a PROTOCOL_ERROR. _handle_window_update_frame (line 424-431) likewise accepts a zero increment (must be PROTOCOL_ERROR) and lets the window exceed 2**31-1 without the required FLOW_CONTROL_ERROR.

So a hostile server advertising a huge MAX_FRAME_SIZE plus inflated windows can steer a large request body into a truncated-length DATA frame.

Fix: validate each setting on receipt and GOAWAY on out-of-range values; in _send_frame, raise instead of masking (if len(payload) > 0xFFFFFF: raise ProtocolError(...)).

        length = len(payload) & 0x00FFFFFF  # 24 bits -> 3 bytes
11. Frame-dispatch error handler logs raw payloads and replies with an illegal RST_STREAM on stream 0
aiohttp/http2/connection.py:186-200

Two problems in this catch-all:

Data exposure. Line 191 dumps a full traceback and line 193 logs the entire raw frame payload at ERROR level. For a HEADERS or DATA frame that is decrypted response content — cookies, bearer tokens, response bodies — written to whatever log sink the application configured, at a level nobody filters out. The HTTP/1.x parser deliberately never logs raw message bytes.

Protocol violation. _send_rst_stream(stream_id, PROTOCOL_ERROR) runs with whatever stream_id the frame carried, including 0 for SETTINGS, PING and GOAWAY frames. RFC 9113 §6.4 requires the peer to treat RST_STREAM on stream 0 as a connection error, so an internal error while handling any connection-level frame makes the server kill the connection. Connection-level failures should use _send_goaway.

Also, FrameType(frame_type_val) at line 195 raises ValueError for an unknown frame type — inside the exception handler — masking the original error. Line 177 guards the debug log for exactly this reason; this log is not guarded.

Fix: log the frame type/flags/stream id and the exception only (payload length at most, never bytes, and the traceback at debug level), reuse the already-computed name rather than re-constructing the enum, and branch to _send_goaway when stream_id == 0.

            try:
                self._dispatch_frame(frame_type_val, flags, stream_id, payload)
            except Exception as exc:
                # this should catch any protocol violation
                # so we really don't want to swallow this exception
                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)
12. `send_data(..., end_stream=True)` sends at most one frame, silently truncating larger bodies
aiohttp/http2/connection.py:580-588

The unconditional if end_stream: break exits after the first frame. So when total exceeds min(MAX_FRAME_SIZE, stream.outbound_window, session_outbound_window):

  • only chunk_size bytes are written,
  • END_STREAM is not set (the offset + chunk_size >= total guard on line 562 is false),
  • and the loop exits anyway.

Result: a truncated request body on a stream the server will wait on forever. With MAX_FRAME_SIZE at 16384 this triggers at a 16 KiB body.

Today's client callers happen to avoid it — _write_bytes always calls write_eof() with no argument, so the end_stream=True call carries b"" — but Http2StreamWriter.write_eof(chunk) explicitly implements the non-empty-chunk case (adapter.py:165-179) and write_eof(chunk) is part of the AbstractStreamWriter contract that server-side code already uses. It is a live landmine one caller away.

Fix: drop the break and let while offset < total terminate the loop; keep a separate guard for the total == 0 and end_stream case so it emits exactly one empty DATA frame.

            # avoid sending infinite zero-length DATA frames
            if end_stream:
                break

🟢 Suggestions

13. Oversized frame sends GOAWAY repeatedly without closing the connection or draining the buffer
aiohttp/http2/connection.py:155-162

On length > max_frame the handler sends GOAWAY and returns, but leaves the offending bytes in self._frame_buffer and does not close the transport. Every subsequent data_received re-enters the loop, re-reads the same oversized header, and emits another GOAWAY — one per inbound TCP segment.

_goaway_sent is set but never consulted in data_received. Compare _protocol_error() (line 672-674), which does close the transport.

Use self._protocol_error()-style handling here: GOAWAY with FRAME_SIZE_ERROR, clear the buffer, close the transport. Guarding the top of data_received with if self._goaway_sent: return would also make the method cheap to call after shutdown.

            if length > max_frame:
                logger.error(
                    "Received frame of length %d exceeding MAX_FRAME_SIZE %d",
                    length,
                    max_frame,
                )
                self._send_goaway(0, ErrorCode.FRAME_SIZE_ERROR)
                return
14. Write-only state: `_closed_streams` grows one int per request and is never read
aiohttp/http2/connection.py:129-133

Verified with git grep: _closed_streams is only ever written (line 500), _error_code only written (line 401), and Stream.closed_event (stream.py:119, stream.py:131) is set but never awaited. _last_stream_id is read only in a log message.

_closed_streams matters beyond tidiness: it accumulates one integer per completed stream for the whole life of the connection, which for a long-lived multiplexed connection is exactly the workload this PR optimizes for. Either use it (a genuine need — recognizing frames for recently-closed streams, per RFC 9113 §5.1) or drop it.

closed_event allocates an asyncio.Event per stream for no consumer.

Related dead addition: HttpVersion2 (http_writer.py:45) has no callers and is not exported from aiohttp/http.py or aiohttp/__init__.py, while adapter.feed_headers hand-writes HttpVersion(2, 0). Either export it and use it there, or drop it.

        self._error_code: int = 0

        # Closed streams cleanup
        self._closed_streams: Set[int] = set()
15. Four SSL contexts are now built at import time, and the loop variable leaks into the module namespace
aiohttp/connector.py:961-974

The comprehension doubles import-time work: two ssl.create_default_context() calls now load CA certificates from disk instead of one. The comment right below explicitly frames this as a cost paid at import, and aiohttp watches import time closely.

The product over OPTIONAL_PROTOCOLS = 1 also builds an unverified/h2 context that nothing can request — _get_ssl_context only ever asks for (False, x) or (True, x) with x = self._http2_enabled, so of the four, the h2 pair is unused unless h2 is on.

A lazier shape keeps the current behaviour without the extra cert load: keep _SSL_CONTEXT_VERIFIED/_SSL_CONTEXT_UNVERIFIED as-is and build the two h2 variants on first use (or in TCPConnector.__init__ when http2_enabled=True, which is already off the event loop path in the same way).

Minor: for verified in (True, False) at module scope leaves a module-global named verified behind, shadowing the parameter name of _make_ssl_context for any reader.

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)

Checklist

  • No hardcoded secrets or credentials
  • Untrusted peer input validated at the protocol boundary — critical #4, warning #10
  • No sensitive data written to logs — warning #11
  • Errors surface as library exception types, not bare builtins/assertions — warning #7
  • Resources bounded: no unbounded buffers or collections — critical #2, critical #3, warning #5, warning #9, suggestion #14
  • Cleanup on error paths does not affect unrelated work — critical #1
  • Backward compatibility for existing (HTTP/1.1) users preserved — warning #5
  • New public API works as written and is typed — warning #6
  • New behaviour covered by meaningful tests — warning #9
  • Docs updated for user-visible API changes
  • Changelog fragment present and signed

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

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


Silent Failure Analysis

🔴 **16. CRITICAL** — catch-all exception swallowed in frame loop
aiohttp/http2/connection.py:205-222

Risk: Every failure in frame handling — struct.unpack on truncated GOAWAY/RST/WINDOW_UPDATE payloads, ProtocolError from the stream state machine, HPACK/latin-1 encoding errors, even internal bugs — is converted into a per-stream RST and the loop keeps parsing a connection whose HPACK and flow-control state is now desynchronised; when stream_id is 0 it also emits an illegal RST_STREAM on the control stream.

try:
    self._dispatch_frame(frame_type_val, flags, stream_id, payload)
except Exception as exc:
    # this should catch any protocol violation
    logger.error(...)
    self._send_rst_stream(stream_id, ErrorCode.PROTOCOL_ERROR)

Fix: Distinguish connection-level errors (send GOAWAY and close the transport) from stream-level errors, only catch the specific protocol exceptions, and let unexpected exceptions terminate the connection rather than resuming the parse loop.

🟠 **17. HIGH** — error branch returns without draining buffer or closing
aiohttp/http2/connection.py:168-180

Risk: The oversized frame is left in _frame_buffer and the transport is never closed, so every subsequent data_received re-enters this branch — emitting a GOAWAY per call while the buffer grows without bound from a peer that keeps sending.

if length > max_frame:
    logger.error("Received frame of length %d exceeding MAX_FRAME_SIZE %d", length, max_frame)
    self._send_goaway(0, ErrorCode.FRAME_SIZE_ERROR)
    return

Fix: Close the transport (as _protocol_error does) after sending the GOAWAY, and clear _frame_buffer.

🟠 **18. HIGH** — waiters never woken on failure (silent hang)
aiohttp/http2/connection.py:232-243

Risk: Neither connection_lost nor _cancel_streams (used by GOAWAY and by Http2Protocol._on_read_timeout) sets _flow_control_updated, so a request blocked on a zero flow-control window hangs forever after the connection dies instead of raising the connection error.

while total and (stream.outbound_window <= 0 or self.session_outbound_window <= 0):
    self._flow_control_updated.clear()
    await self._flow_control_updated.wait()

Fix: Set _flow_control_updated in connection_lost and _cancel_streams, and re-check connection liveness after each wait() so the loop raises the recorded error.

🟠 **19. HIGH** — swallowed ImportError
aiohttp/http2/connection.py:27-30

Risk: The ImportError is discarded entirely, so any path that reaches Http2Connection.__init__ without the TCPConnector(http2_enabled=...) guard (e.g. HttpDispatcherProtocol seeing an h2 ALPN from a user-supplied SSLContext) fails with a bare NameError: Encoder instead of a diagnosable missing-dependency error.

try:
    from hpack import Decoder, Encoder
except ImportError:
    pass
...
HTTP2_ENABLED = "Decoder" in globals()

Fix: Store the import failure (Encoder = None) and raise an explicit ImportError("hpack is required for HTTP/2") in Http2Connection.__init__/Http2Protocol.connection_made when it is unavailable.

🟠 **20. HIGH** — over-broad cleanup destroys shared resource on error path
aiohttp/client.py:297-305

Risk: For h2 the transport is shared by every multiplexed stream, so one request failing before resp.start() force-closes the connection and kills all other in-flight requests, which then surface as generic ConnectionError with no trace of the originating failure.

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

Fix: On the h2 path reset only the failing stream (RST_STREAM) instead of calling conn.close(), reserving connection teardown for connection-level errors.

🟠 **21. HIGH** — unbounded state growth + permanently disabled guard
aiohttp/http2/synchro.py:32-50

Risk: _done is never pruned, so it grows one entry per connection key for the connector's lifetime, and because client.py calls semaphore.release(key) in a finally even when connector.connect raises, a single failed first connection permanently disables ALPN-probe serialisation for that host — silently reintroducing the duplicate-h2-connection problem this class exists to prevent.

if key in self._done:
    # Already released (no-op)
    return
...
self._locked.remove(key)
self._done.add(key)

Fix: Key the _done marker to the live connection (clear it when the connection is dropped) and only mark a key done after a successful probe.

🟡 **22. MEDIUM** — missing validation, invalid values accepted silently
aiohttp/http2/connection.py:300-330

Risk: Peer SETTINGS values are accepted without the RFC 9113 §6.5.2 range checks (INITIAL_WINDOW_SIZE > 2^31-1, MAX_FRAME_SIZE outside 16384..16777215, ENABLE_PUSH not 0/1), and remote_settings[MAX_FRAME_SIZE] is then used to size outgoing DATA frames, so a bogus value silently produces frames the peer will reject.

setting = Setting(identifier)
old_value = self.remote_settings.get(setting, value)
self.remote_settings[setting] = value

Fix: Validate each setting's range and respond with FLOW_CONTROL_ERROR / PROTOCOL_ERROR GOAWAY instead of storing the value.

🟡 **23. MEDIUM** — unvalidated input, unknown stream silently ignored
aiohttp/http2/connection.py:365-378

Risk: A zero increment (a mandatory PROTOCOL_ERROR) and a window pushed past 2^31-1 (FLOW_CONTROL_ERROR) are both accepted silently, and a WINDOW_UPDATE for an unknown stream is dropped with no log, making desync between client and server invisible.

increment = struct.unpack("!I", payload)[0]
if stream_id == 0:
    self.session_outbound_window += increment
else:
    stream = self.streams.get(stream_id)
    if stream:
        stream.outbound_window += increment

Fix: Reject increment == 0 and window overflow with the appropriate error frames, and log the unknown-stream case.

🟡 **24. MEDIUM** — log-only error with no recovery
aiohttp/http2/connection.py:258-266

Risk: Headers for an unknown stream are decoded (mutating shared HPACK dynamic-table state) and then dropped with only a log line, so the response is silently lost and no RST_STREAM/GOAWAY tells the peer anything is wrong.

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 (or GOAWAY for a stream ID above the last issued one) rather than only logging.

🟡 **25. MEDIUM** — error detail discarded
aiohttp/http2/connection.py:334-352

Risk: _handle_goaway_frame parses error_code and the debug payload but _cancel_streams replaces them with a generic RuntimeError, so the caller never learns whether the peer sent NO_ERROR, ENHANCE_YOUR_CALM, or INADEQUATE_SECURITY — information needed to decide whether to retry.

exc = exc or RuntimeError("GOAWAY sent or received")
for sid, stream in list(self.streams.items()):
    if sid > last_stream_id:
        self._close_stream(stream, exc)

Fix: Pass the decoded ErrorCode and debug data into the exception raised on the cancelled streams.

🟡 **26. MEDIUM** — fallback value hides missing state / return-type lie
aiohttp/http2/adapter.py:18-32

Risk: A protocol object in an unexpected shape silently reports HTTP/1.1 (routing an h2 connection down the HTTP/1.1 writer path), and selected_alpn_protocol() returns None when ALPN fails, which is returned despite the declared -> str and then compared against "h2" everywhere.

if not hasattr(protocol, "transport"):
    return "http/1.1"
...
alpn_protocol = ssl_object.selected_alpn_protocol() if ssl_object else "http/1.1"
return alpn_protocol

Fix: Type the return as str | None, normalise None to "http/1.1" explicitly, and let a missing transport attribute raise rather than defaulting.

🟡 **27. MEDIUM** — parameter silently discarded / exception never recorded
aiohttp/http2/connection.py:846-858

Risk: exc_cause — whose sole purpose is to attach __cause__ for traceback context — is accepted and dropped, and unlike ResponseHandler the exception is never stored, so nothing can query it afterwards and a set_exception arriving before connection_made vanishes entirely.

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

Fix: Store the exception in an _exception attribute (with a matching exception() accessor) and propagate exc_cause through to the stream futures via helpers.set_exception.

🟡 **28. MEDIUM** — abort silently downgraded to graceful close
aiohttp/http2/connection.py:760-762

Risk: abort() is aliased to close(), which calls transport.close() (a graceful flush-then-close) rather than transport.abort(), so callers that abort specifically to drop a wedged connection immediately get an unbounded wait instead and no indication the request was ignored.

def abort(self) -> None:
    self.close()

Fix: Call transport.abort() in abort(), or document the deviation explicitly if graceful close is intentional.

🟡 **29. MEDIUM** — early return skips end-of-stream handling
aiohttp/http2/stream.py:215-224

Risk: The early return for 1xx responses ignores the end_stream flag and leaves self.response pointing at the informational message, so a HEADERS frame carrying both a 1xx status and END_STREAM never closes the stream nor resolves response_future — the request hangs until the client timeout.

if self.response.code >= 100 and self.response.code <= 199:
    # info, skip
    return
self._headers_received = True

Fix: Handle end_stream before returning (treat 1xx + END_STREAM as a protocol error, per RFC 9113 §8.1) and avoid overwriting self.response with the informational message.

🟡 **30. MEDIUM** — fallback to None hides unsupported encoding
aiohttp/http2/adapter.py:59-63

Risk: feed_headers normalises an unrecognised content-encoding to compression=None, but Stream.receive_headers reads the raw header instead of msg.compression, so the two disagree: the response reports no compression while the stream still tries (and fails) to build a DeflateBuffer for the unknown codec.

encoding = None
enc = ci_headers.get(hdrs.CONTENT_ENCODING, "")
if enc.isascii() and enc.lower() in {"gzip", "deflate", "br", "zstd"}:
    encoding = enc

Fix: Have Stream.receive_headers use self.response.compression so the normalisation decision is made in exactly one place.

🟡 **31. MEDIUM** — log emitted to wrong (root) logger
aiohttp/http2/stream.py:186-190

Risk: logging.warning uses the root logger rather than the module's logger, so this pre-headers overflow warning bypasses any aiohttp.http2.* log configuration and is invisible to operators who filter by logger name.

if len(self._pending_data) + len(data) > self.limit:
    msg = "Received too much data before headers."
    logging.warning(msg)
    self.conn._send_rst_stream(self.stream_id, ErrorCode.INTERNAL_ERROR)

Fix: Use logger.warning(msg) to match the rest of the module.

🟡 **32. MEDIUM** — option read but never consumed / silently ignored
aiohttp/client.py:1656-1658

Risk: http2_enabled is read with .get rather than .pop, so it is also forwarded to session._request(**kwargs) which has no such parameter, and when the caller supplies their own connector the flag is never consulted at all — silently producing an HTTP/1.1-only session.

http2_enabled = kwargs.get("http2_enabled", False)
connector = TCPConnector(force_close=True, http2_enabled=http2_enabled)

Fix: Use kwargs.pop("http2_enabled", False) and raise a ValueError if it is passed together with an explicit connector.

🟡 **33. MEDIUM** — no-op ImportError guard
tests/conftest.py:41-45

Risk: The handler binds an unrelated name http2 that nothing ever reads, so Http2Connection/Http2Protocol stay undefined and the protocol/connection fixtures fail later with a confusing NameError instead of a clear skip when hpack is absent.

try:
    from aiohttp.http2.connection import Http2Connection, Http2Protocol
except ImportError:
    http2 = None

Fix: Set Http2Connection = Http2Protocol = None and have the fixtures pytest.skip("hpack not installed") when they are None.

🟡 **34. MEDIUM** — test with no assertions
tests/http2/test_decompress.py:11-36

Risk: The test's docstring describes an assertion failure inside DeflateBuffer.feed_eof(), but it asserts nothing and Stream.receive_data catches ContentEncodingError internally, so the test passes whether or not the decompression path is correct.

async def test_deflate_buffer_assertion_on_unflushed_data(connection: Any) -> None:
    ...
    stream.receive_data(compressed, end_stream=True)

Fix: Assert the expected outcome explicitly — e.g. that stream.body_reader.exception() is a ContentEncodingError, or that the full raw payload is readable.

🟡 **35. MEDIUM** — fuzz oracle neutralised by upstream catch-all
tests/http2/test_fuzz.py:8-15

Risk: The fuzzer feeds frames through Http2Connection.data_received, which swallows every exception from _dispatch_frame, so the crashes and protocol violations this test exists to find are converted into log lines and the assertion can essentially never fire.

fuzzer = Http2ServerFuzzer(conn, transport, config, max_cycles=50)
bugs = fuzzer.run()
assert not bugs, f"Bugs found: {bugs}"

Fix: Install a log/exception capture (or a hook on the catch-all) so the fuzzer's verification treats any swallowed dispatch exception as a reported bug.


Automated review by Kōan (Claude) HEAD=e6c0104 20 min 21s

@Moist-Cat

Moist-Cat commented Sep 20, 2026 •

Copy link
Copy Markdown
Author

I ported the fuzzers and found a few bugs:

  • When Setting.MAX_CONCURRENT_STREAMS/Setting.INITIAL_WINDOW_SIZE is zero, the connection hangs.
  • Pending streams would wait forever in some cases (e.g., connection lost)
  • DoS caused by send_data sending infinite zero-length DATA frames when the max frame size is zero
  • Malformed headers and invalid stream transitions are handled by a catch-all exception handler

Some of these are a bit "silly" but they show that the fuzzer is working.

By the way, there is overlap between unit tests and the fuzzy tests so I'm wondering if we can somehow detect and prune them or at least include the fuzzy tests in the coverage to prevent contributors from writing unnecessary tests by hand in the future.

@Dreamsorcerer

Copy link
Copy Markdown
Member

Going to start looking at reviewing this soon.

@Dreamsorcerer

Copy link
Copy Markdown
Member

OK, I'm now reading through RFC 9113 and getting familiar with everything.

First, the high level architecture. I feel like the current approach is likely to cause issues, and we should be treating streams as the focal point going forwards. So, I feel like we need an abstraction something along these lines (from a Claude suggestion):

class HttpConnection(Protocol):          # one TCP conn (h1/h2) or QUIC conn (h3)
    def can_start_stream(self) -> bool   # h1: not busy; h2/h3: active < min(local, peer MAX_CONCURRENT_STREAMS)
    def start_stream(self) -> HttpStream
    def graceful_close(self) -> None     # h1: Connection: close; h2/h3: GOAWAY
    should_close: bool                   # replaces ResponseHandler.should_close

class HttpStream(Protocol):              # one exchange; h1 impl = thin shim over today's objects
    async def send_head(self, head: RequestHead | ResponseHead) -> None
    writer: AbstractStreamWriter         # body out; write() awaits stream+conn window on h2/h3
    payload: StreamReader                # body in; reads grant window credit back
    async def send_trailers(self, trailers: CIMultiDict[str]) -> None
    def reset(self, code: int) -> None   # h1: close conn; h2/h3: RST_STREAM

So, HTTP/1.1 is basically 1 HTTPStream per HttpConnection, HTTP/2|3 will have multiple.

So, I'm feeling like we should probably have a PR that refactors the code to follow a shape like this, and then add a stacked PR on top that implement the h2 version, with all the tests and fuzzers from here included.

Also, is there a reason the h2 library hasn't been used for any of the implementation?

@Moist-Cat

Copy link
Copy Markdown
Author

@Dreamsorcerer So, HTTP/1.1 is basically 1 HTTPStream per HttpConnection, HTTP/2|3 will have multiple.

Yes, HTTP/1.1 can be seen as a particular case of HTTP/2. If we separate the logic in charge of the request/response exchange from the Connection class we can isolate the cases where the implementations differ. Notice that this process is not straightforward as we have to touch all the classes that read/write bytes--namely, ClientRequest, ClientResponse and StreamReader--and the only immediate benefit is eliminating less than 3 if statements. If we change the architecture to make it consistent with the concept of "streams" we might end up making more changes than expected so, I believe it's better to be cautious.

That said, I will see what I can do.

@Moist-Cat

Copy link
Copy Markdown
Author

I think it would be more beneficial to refactor the Connection class to allow connection reuse a la HTTP/2. This, however, affects the HTTP/1.1 implementation as well.

@Moist-Cat

Moist-Cat commented Oct 9, 2026 •

Copy link
Copy Markdown
Author

Also, is there a reason the h2 library hasn't been used for any of the implementation?

I can tell at a glance that the library is well written but the scope of the implementation isn't clear to me. I might be wrong but I believe dependencies should be replaceable and focused so I can't add add a dependency if I don't know what specific problem does it solve.

@Dreamsorcerer

Dreamsorcerer commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

If we separate the logic in charge of the request/response exchange from the Connection class we can isolate the cases where the implementations differ. Notice that this process is not straightforward as we have to touch all the classes that read/write bytes--namely, ClientRequest, ClientResponse and StreamReader--and the only immediate benefit is eliminating less than 3 if statements.

Yeah, my concern is that the current code appears to already have several security vulnerabilities and I'm under the impression that resolving them will need significantly more code changes anyway. It's better that we refactor now to architect this better and have a cleaner approach for future maintenance that makes it easier to deal with future security fixes etc.

I can tell at a glance that the library is well written but the scope of the implementation isn't clear to me. I might be wrong but I believe dependencies should be replaceable and focused so I can't add add a dependency if I don't know what specific problem does it solve.

My understanding is that it effectively handles the HTTP/2 parsing, including connection and stream state. It's already used by httpx, for example. We can dig into it later and compare between your code and a potential h2 dependency and decide then if it's worth switching over or if we should just manage it here.

On that note, I'd also point out that the same group also have hyperframe for working with HTTP/2 frames, and hpack for handling HTTP/2 headers (which you've already chosen to use).

@Dreamsorcerer

Copy link
Copy Markdown
Member

My understanding is that it effectively handles the HTTP/2 parsing, including connection and stream state. It's already used by httpx, for example. We can dig into it later and compare between your code and a potential h2 dependency and decide then if it's worth switching over or if we should just manage it here.

At a very brief glance, I get the impression h2 would replace the majority of code under http2/ currently.

@Moist-Cat
Moist-Cat marked this pull request as draft October 11, 2026 13:17
try:
if fut.done() and not fut.cancelled():
fut.exception()
except BaseException:
try:
if fut.done() and not fut.cancelled():
fut.exception()
except BaseException:
Comment thread fuzzers/fuzz_http2_connection.py Fixed
Comment thread aiohttp/http2/adapter.py Fixed
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/settings.py Fixed
Comment thread aiohttp/http2/stream.py Fixed
Comment thread aiohttp/http2/synchro.py Fixed
Comment thread aiohttp/http_writer.py Fixed
Comment thread tests/http2/fuzz.py Fixed
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2_adapter.py Fixed
Comment thread tests/http2/utils.py Fixed
Comment thread aiohttp/http2/adapter.py Fixed
Comment thread aiohttp/http2/connection.py Fixed
Comment thread aiohttp/http2/stream.py Fixed
Comment thread aiohttp/http_writer.py Fixed
Comment thread fuzzers/fuzz_connector.py Fixed
Comment thread fuzzers/fuzz_connector.py
host_id, req, task = pending.pop(cid)
try:
connection = task.result()
except BaseException as e:
Comment thread fuzzers/fuzz_connector.py
try:
connection = task.result()
live[cid] = LiveConn(cid, host_id, connection, req)
except BaseException:
Comment thread fuzzers/fuzz_connector.py
try:
connection = task.result()
live[cid] = LiveConn(cid, host_id, connection, req)
except BaseException:
Comment thread tests/http2/test_http2.py Fixed
Comment thread tests/http2/test_http2_adapter.py Fixed

This branch has not been deployed

No deployments
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