Conversation
Under asyncio, connections now use a backend built directly on the event loop's transports and protocols instead of anyio streams. Each read and write no longer sets up a cancel scope with a loop timer and an exception-mapping context manager: a read returns buffered data immediately when there is some and otherwise waits on a plain future with an optional timer, and a write hands the data to the transport and only waits when the transport applies backpressure. The protocol keeps reading while a connection is idle, bounded by a high-water mark, so a server close is known without probing the socket. anyio remains in use for trio and for the synchronization primitives, and AnyIOBackend stays available for explicit use. The integration tests now run against both the automatic backend and the explicit anyio backend, and a dedicated test module covers the asyncio backend's timeouts, end-of-stream and connection-loss handling, backpressure, TLS and unix sockets.
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Setting a socket option after the connection is established now closes the connection and raises ConnectError, like any other failure to establish a usable connection, instead of leaking the transport and raising a raw OSError.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bfaa5bbba6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| loop = asyncio.get_running_loop() | ||
| local_addr = None if local_address is None else (local_address, 0) | ||
| # By default TCP sockets opened in `asyncio` include TCP_NODELAY. | ||
| connect = loop.create_connection(AsyncioStreamProtocol, host, port, local_addr=local_addr) |
There was a problem hiding this comment.
Preserve Happy Eyeballs when opening TCP connections
When a hostname resolves to both IPv6 and IPv4 and the first address silently drops connection attempts, calling create_connection without happy_eyeballs_delay tries addresses sequentially. Because AutoBackend now selects this implementation, requests that previously used AnyIO's staggered Happy Eyeballs attempts can exhaust the entire connect timeout on the unreachable address without ever reaching a working address from the other family.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Done in 6d7231d: connect_tcp passes happy_eyeballs_delay=0.25 on event loops whose create_connection supports it (detected once per loop class; zuvloop's does not take it), matching the anyio behaviour (covered by test_happy_eyeballs_is_used_where_supported).
| loop = asyncio.get_running_loop() | ||
| # The loop reports its own handshake timeout as a connection error, | ||
| # so the deadline is applied here instead to raise a timeout. | ||
| handshake = loop.start_tls(self._transport, self._protocol, ssl_context, server_hostname=server_hostname) |
There was a problem hiding this comment.
Align the transport's TLS deadline with the requested timeout
For a TLS handshake lasting more than 60 seconds, loop.start_tls applies asyncio's own default 60-second handshake timeout because ssl_handshake_timeout is omitted. Thus timeout=None still fails after 60 seconds, and a configured timeout greater than 60 fails early; asyncio raises ConnectionAbortedError, which this method maps to ConnectError rather than the expected ConnectTimeout at the caller's deadline.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Done in 6d7231d: the loop's ssl_handshake_timeout is now derived from the requested timeout (timeout + 1, or effectively unbounded for None) so the backend's own deadline is the one that fires and raises ConnectTimeout.
start_tls now refuses to proceed if any data was received before the handshake, since it would be plaintext that must not be mistaken for data received over TLS, and it sets the event loop's own handshake timeout from the requested timeout so the requested deadline is the one that fires. connect_tcp staggers attempts across resolved addresses (Happy Eyeballs) on event loops that support it, as the anyio backend did.
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Detecting Happy Eyeballs support inspects the event loop's create_connection signature; extension-implemented loops may expose none, in which case the feature is treated as unsupported rather than failing every connection.
The client worker can drive httpx as well as httpx2, since the two share an API, giving the comparison a reference point for the package httpx2 forked from.
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="benchmark/client.py">
<violation number="1" location="benchmark/client.py:73">
P2: When `--lib httpx` is selected through the documented benchmark environment, this import fails because the original `httpx` package is not declared or locked by the project. Add `httpx` to the benchmark dependency group, or document and provision it separately.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
|
||
| def build_httpx(scenario: Scenario, payload: bytes) -> tuple[RequestFn, CloseFn]: | ||
| # The original httpx, for reference; it shares the httpx2 API. | ||
| import httpx |
There was a problem hiding this comment.
P2: When --lib httpx is selected through the documented benchmark environment, this import fails because the original httpx package is not declared or locked by the project. Add httpx to the benchmark dependency group, or document and provision it separately.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At benchmark/client.py, line 73:
<comment>When `--lib httpx` is selected through the documented benchmark environment, this import fails because the original `httpx` package is not declared or locked by the project. Add `httpx` to the benchmark dependency group, or document and provision it separately.</comment>
<file context>
@@ -65,6 +65,18 @@ def check_size(received: int, expected: int) -> None:
+
+def build_httpx(scenario: Scenario, payload: bytes) -> tuple[RequestFn, CloseFn]:
+ # The original httpx, for reference; it shares the httpx2 API.
+ import httpx
+
+ return _build_httpx_like(httpx, scenario, payload)
</file context>
There was a problem hiding this comment.
Done in c6d2d00, by documenting it: httpx cannot join the bench group because installing it next to httpx2 breaks the alias tests' type-checks, so the README now shows how to provision httpx/punkreq in a separate interpreter passed via --python, and the import site says so.
…rence packages The sync connection pool has a single backend, so the generated sync integration tests no longer parametrize over a duplicate of it. The benchmark README explains how to provision httpx and punkreq in a separate interpreter, since neither belongs in the project environment.
Summary
Stacked on #1169. Under asyncio,
httpcore2connections now use a backend built directly on the event loop's transports and protocols instead of anyio streams.What the anyio path costs per socket operation: an
anyio.fail_aftercancel scope (with a loop timer), amap_exceptions@contextmanager, asleep(0)checkpoint on every send, and apause_reading()/resume_reading()toggle around every receive. The native backend removes all of that:call_latertimer. Chunks larger thanmax_bytesare sliced, smaller ones handed over as-is.pause_writing).get_extra_info("is_readable")answers from state and a server-side close of an idle connection is known without apoll()syscall.loop.start_tls, with the handshake deadline enforced by the backend so a slow handshake raisesConnectTimeout;aclose()yields once so the socket is really closed when it returns.try/exceptto the samehttpcore2exception types as before.AutoBackendselects it under asyncio and still selectsTrioBackendunder trio. anyio remains a dependency for trio and for the synchronization primitives;AnyIOBackendstays available for explicit use and is exported alongside the newAsyncioBackend.Benchmarks
scripts/benchmark --lib httpx2 --python step1=... --python step2=..., CPython 3.14 + zuvloop, 2 vCPU, interleaved rounds (medians).step1is #1169,step2is this PR on top of it:Per-request CPU drops 14-18% on small requests and throughput rises 15-32%; large bodies gain 13-26% from cheaper reads. Against
mainbefore #1169, c16 is now 2,600 → 4,070 rps and c512 is 790 → 4,026 rps.Tests
tests/httpcore2/test_asyncio_backend.py(asyncio only) covers the roundtrip and extra info, EOF, read timeout, connect refused/timeout, unix sockets, TLS success/failure/timeout, and, through a fake transport, chunk slicing, backpressure, pending reads woken by EOF or connection loss, write drain/timeout/loss, andaclose(). The integration tests are parametrized over the automatic backend and the explicitAnyIOBackendso the anyio path stays covered.Checklist
🤖 Generated with Claude Code