Skip to content

Add support for HTTP/2 - #13039

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

Add support for HTTP/2#13039
Moist-Cat wants to merge 40 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
  • Chunking
  • CONTINUATION frames for very large headers
  • h2c (cleartext) not supported.
  • Ensure all the high-level configuration/parameters work (or make sense for) with HTTP/2 as well

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 25.74310% with 1399 lines in your changes missing coverage. Please review.
✅ Project coverage is 22.15%. Comparing base (c52fe79) to head (d8dc63a).
⚠️ Report is 6 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
tests/http2/test_http2.py 22.78% 783 Missing ⚠️
aiohttp/http2/connection.py 18.54% 325 Missing ⚠️
tests/http2/test_http2_adapter.py 20.00% 112 Missing ⚠️
aiohttp/http2/stream.py 23.07% 90 Missing ⚠️
aiohttp/http2/adapter.py 33.69% 60 Missing and 1 partial ⚠️
aiohttp/client.py 42.30% 10 Missing and 5 partials ⚠️
aiohttp/http_protocol.py 83.33% 3 Missing and 2 partials ⚠️
aiohttp/client_reqrep.py 63.63% 2 Missing and 2 partials ⚠️
aiohttp/connector.py 60.00% 3 Missing and 1 partial ⚠️

❗ There is a different number of reports uploaded between BASE (c52fe79) and HEAD (d8dc63a). Click for more details.

HEAD has 181 uploads less than BASE
Flag BASE (c52fe79) HEAD (d8dc63a)
cython-coverage 4 0
Py-3.10 8 0
OS-macOS 10 0
CI-GHA 44 0
VM-macos 10 0
Py-3.11 8 0
OS-Windows 10 0
VM-windows 10 0
Py-3.12 8 0
Py-3.13 8 0
Py-3.14 8 0
VM-ubuntu 24 0
OS-Linux 24 0
Autobahn 2 1
Py-3.14t 2 0
Py-pypy-3.11 2 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #13039       +/-   ##
===========================================
- Coverage   99.02%   22.15%   -76.87%     
===========================================
  Files         135      140        +5     
  Lines       50486    51402      +916     
  Branches     2650     2776      +126     
===========================================
- Hits        49993    11388    -38605     
- Misses        370    39488    +39118     
- Partials      123      526      +403     
Flag Coverage Δ
Autobahn 22.15% <25.74%> (+0.11%) ⬆️
CI-GHA ?
OS-Linux ?
OS-Windows ?
OS-macOS ?
Py-3.10 ?
Py-3.11 ?
Py-3.12 ?
Py-3.13 ?
Py-3.14 ?
Py-3.14t ?
Py-pypy-3.11 ?
VM-macos ?
VM-ubuntu ?
VM-windows ?
cython-coverage ?

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 degrade performance by 8.18%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
❌ 15 regressed benchmarks
✅ 77 untouched benchmarks
⏩ 83 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_one_thousand_round_trip_websocket_text_messages 30.8 ms 35.8 ms -14.11%
test_one_thousand_round_trip_websocket_binary_messages[tcp-small] 30.2 ms 35 ms -13.8%
test_one_hundred_simple_get_requests_multiple_methods_route 82.4 ms 93.2 ms -11.58%
test_one_hundred_simple_get_requests[tcp] 83.2 ms 93.8 ms -11.33%
test_one_hundred_simple_get_requests_alternating_clients 84.7 ms 95.3 ms -11.11%
test_one_hundred_get_requests_with_1024_content_length_payload 89.7 ms 100.4 ms -10.65%
test_one_hundred_get_requests_with_1024_chunked_payload[tcp] 90.9 ms 101.7 ms -10.61%
test_ten_web_middlewares 90 ms 100.6 ms -10.57%
test_one_hundred_simple_get_requests[ssl] 96.2 ms 107 ms -10.16%
test_one_hundred_simple_post_requests 93.7 ms 104.1 ms -9.97%
test_one_hundred_get_requests_with_1024_chunked_payload[ssl] 104.9 ms 115.9 ms -9.5%
test_one_hundred_get_requests_with_30000_chunked_payload 104.8 ms 115.7 ms -9.41%
test_one_hundred_get_requests_with_30000_content_length_payload 104 ms 114.7 ms -9.33%
test_one_hundred_json_post_requests 98.9 ms 108.9 ms -9.2%
test_one_thousand_round_trip_websocket_binary_messages[ssl-small] 53.6 ms 58.6 ms -8.51%
test_list_element_pattern_redos_payload[whitespace_run_after_content] 31 ms 27.2 ms +13.91%
test_read_base64_part 11.8 ms 10.5 ms +11.83%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing Moist-Cat:master (1028739) with master (4047e5b)2

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.

  2. No successful run was found on master (5889927) during the generation of this report, so 4047e5b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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

Copy link
Copy Markdown
Member

h2c is widely unsupported anyway (no major browser supports it), so we don't need to focus on that. If it's easy to add later, we can do so, but let's try not to expand the scope of this current work.

@egormanga

Copy link
Copy Markdown

h2c is not for browsers. It's often used as local inter-service protocol.

Comment thread aiohttp/http2/response.py Fixed
Comment thread aiohttp/http2/response.py Fixed
Comment thread aiohttp/http2/response.py Fixed
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown

Confidence Score: 0/5

The PR is not safe to merge because remote HTTP/2 peers can exhaust client memory or break frame processing while configured response behavior is also ignored.

Response bodies are buffered and flow-control credit is replenished without application consumption, decompression has no output bound, malformed frames can escape the protocol callback and strand active streams, and HTTP/2 responses disregard auto_decompress configuration.

Files Needing Attention: aiohttp/http2/stream.py, aiohttp/http2/response.py, aiohttp/http2/connection.py, aiohttp/client.py

Security Review

The HTTP/2 path remains vulnerable to peer-driven memory exhaustion through unbounded response buffering and decompression, while malformed control and DATA frames can escape protocol callbacks and strand multiplexed requests.

Reviews (2): Last reviewed commit: "Merge branch 'aio-libs:master' into mast..." | Re-trigger Greptile

Comment thread aiohttp/http2/stream.py
Comment on lines +105 to +115
def receive_data(self, data: bytes, end_stream: bool) -> None:
"""Process incoming DATA frame payload."""
self.inbound_window -= len(data)
self.response_data.extend(data)

# --- stream-level flow control refill ---
if self.inbound_window < self._inbound_window_initial // 2:
increment = self._inbound_window_initial - self.inbound_window
self.inbound_window = self._inbound_window_initial
# Use the connection’s helper to send the WINDOW_UPDATE frame
self.conn._send_window_update(self.stream_id, increment)

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 Unbounded HTTP/2 response buffering

When an HTTP/2 server sends a large or non-terminating response, this path appends every DATA payload while replenishing flow-control credit and does not return the response until END_STREAM, causing attacker-controlled memory growth with no opportunity for the caller to consume or release partial data.

How this was verified: The receive path continually extends response_data and restores both stream and connection windows before copying the complete buffer into the response future.

Comment thread aiohttp/http2/response.py Outdated
Comment on lines +37 to +40
encoding = self.headers.get(CONTENT_ENCODING, None)
if encoding in {"gzip", "deflate"}:
comp = ZLibDecompressor(encoding=encoding)
body = comp.decompress_sync(body)

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 Auto-decompression setting is ignored

When a caller sets session-level or per-request auto_decompress=False, the HTTP/2 branch bypasses set_response_params and this constructor still decompresses gzip or deflate bodies unconditionally, causing callers to receive altered bytes instead of the encoded representation they requested.

Comment on lines +240 to +245
def _handle_rst_stream_frame(
self, flags: int, stream_id: int, payload: bytes
) -> None:
del flags # rst doesn't use flags

error_code = struct.unpack("!I", 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 Short control frames escape parsing

When an HTTP/2 peer sends a short RST_STREAM, GOAWAY, or WINDOW_UPDATE frame, the corresponding handler unpacks four or eight bytes without validating the payload length, causing struct.error to escape data_received, terminate the multiplexed connection, and fail every active request.

How this was verified: The dispatcher passes peer payloads directly to fixed-size struct.unpack calls without frame-specific length checks or an exception boundary.

Comment thread aiohttp/connector.py
Comment on lines 946 to +953
sslcontext.verify_mode = ssl.CERT_NONE
sslcontext.options |= ssl.OP_NO_COMPRESSION
sslcontext.set_default_verify_paths()
sslcontext.set_alpn_protocols(("http/1.1",))

protocols = ["http/1.1"]
if os.getenv("AIOHTTP_ENABLE_EXPERIMENTAL_PROTOCOLS", False):
protocols += ["h2"]
sslcontext.set_alpn_protocols(tuple(protocols))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 HTTP/2 feature lacks documentation

The new environment-variable opt-in changes ALPN negotiation and introduces user-visible HTTP/2 behavior and limitations, but the PR adds no client reference or narrative documentation, leaving users without shipped guidance for enabling or evaluating the feature.

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!

Comment thread pyproject.toml
"propcache >= 0.2.0",
"typing_extensions >= 4.4 ; python_version < '3.13'",
"yarl >= 1.17.0, < 2.0",
"hpack >= 4.2.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.

P2 Feature lacks changelog fragment

This adds HTTP/2 client support and a mandatory hpack runtime dependency without the required CHANGES/{pr_or_issue}.feature.rst fragment, so generated release notes will omit the new capability and dependency change.

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!

@Dreamsorcerer

Copy link
Copy Markdown
Member

If you want to skip the reviews for now, switch to a draft.

Comment thread aiohttp/http2/response.py Outdated
Comment on lines +37 to +40
encoding = self.headers.get(CONTENT_ENCODING, None)
if encoding in {"gzip", "deflate"}:
comp = ZLibDecompressor(encoding=encoding)
body = comp.decompress_sync(body)

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 Unbounded synchronous response decompression

When an HTTP/2 server returns a highly compressed gzip or deflate body, this constructor decompresses the complete server-controlled payload synchronously without an output limit, causing memory exhaustion and blocking the event loop even if response buffering is made incremental.

How this was verified: decompress_sync is called on the complete body with its default unlimited output length.

Comment on lines +199 to +201
pos = 0
if flags & FlagData.PADDED:
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 Malformed frames strand active streams

When a peer sends an empty DATA frame with PADDED set, this handler reads payload[0] and raises IndexError; similarly, non-UTF-8 GOAWAY debug data raises UnicodeDecodeError. These exceptions escape the receive callback without closing the transport or resolving active response futures, leaving multiplexed requests hanging.

How this was verified: The handlers consume peer-controlled payloads without validation, and the receive path has no exception boundary before the only cleanup in connection_lost.

@Moist-Cat
Moist-Cat marked this pull request as draft August 13, 2026 21:54
@Moist-Cat

Moist-Cat commented Aug 13, 2026

Copy link
Copy Markdown
Author

I noticed that, while ClientResponse is bound to HTTP/1.1 in principle, if we change the writer in ClientResponse and keep track of the HTTP/2 stream_id we can adapt HTTP/2 to the HTTP/1.1 class without creating too many special cases.
This means we might not need a new response class for HTTP/2 after all.

@Moist-Cat

Moist-Cat commented Aug 22, 2026

Copy link
Copy Markdown
Author

It seems #13152 is going to be unnecessary after all. Now that the solution is better integrated with the code I only need to implement/add:

  • Compression
  • Streaming for large downloads (quite complicated, I'm not sure if it will be covered in this PR)
  • Unit tests
  • Synchronise connection internal state machine with external buffer
  • Reduce the scope of the semaphore
  • Review the protocol-switching logic
  • Check if proxies work out-of-the-box
  • Tidy up

By the way, I added web sockets to the missing features. The way the connection is initiated differs between versions.

Comment thread tests/http2/test_http2.py
import pytest
from hpack import Encoder

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants