Skip to content

Add a native asyncio network backend - #1170

Open
Kludex wants to merge 6 commits into
pool-incremental-statefrom
asyncio-backend
Open

Kludex wants to merge 6 commits into
pool-incremental-statefrom
asyncio-backend

Conversation

@Kludex

@Kludex Kludex commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #1169. Under asyncio, httpcore2 connections 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_after cancel scope (with a loop timer), a map_exceptions @contextmanager, a sleep(0) checkpoint on every send, and a pause_reading()/resume_reading() toggle around every receive. The native backend removes all of that:

  • A read returns buffered data immediately when there is some, and otherwise waits on a plain future with an optional call_later timer. Chunks larger than max_bytes are sliced, smaller ones handed over as-is.
  • A write hands the buffer to the transport and only waits when the transport applies backpressure (pause_writing).
  • The protocol keeps reading while a connection is idle, bounded by a 256 KiB high-water mark (resuming below 64 KiB), so get_extra_info("is_readable") answers from state and a server-side close of an idle connection is known without a poll() syscall.
  • TLS uses loop.start_tls, with the handshake deadline enforced by the backend so a slow handshake raises ConnectTimeout; aclose() yields once so the socket is really closed when it returns.
  • Exceptions map with plain try/except to the same httpcore2 exception types as before.

AutoBackend selects it under asyncio and still selects TrioBackend under trio. anyio remains a dependency for trio and for the synchronization primitives; AnyIOBackend stays available for explicit use and is exported alongside the new AsyncioBackend.

Benchmarks

scripts/benchmark --lib httpx2 --python step1=... --python step2=..., CPython 3.14 + zuvloop, 2 vCPU, interleaved rounds (medians). step1 is #1169, step2 is this PR on top of it:

scenario step 1 step 1 + this PR
c1/1k 812 rps · 360 us/req 836 rps · 311 us/req
c16/1k 3,084 rps · 245 us/req 4,070 rps · 207 us/req
c128/1k 3,415 rps · 250 us/req 3,995 rps · 214 us/req
c512/1k 3,515 rps · 245 us/req 4,026 rps · 212 us/req
c64/100k 2,585 rps · 329 us/req 2,709 rps · 313 us/req
c8/5m 166 rps · 3926 us/req 209 rps · 3270 us/req
c64/1k POST 3,157 rps · 271 us/req 3,925 rps · 218 us/req
c8/1m POST 263 rps · 1534 us/req 298 rps · 1289 us/req

Per-request CPU drops 14-18% on small requests and throughput rises 15-32%; large bodies gain 13-26% from cheaper reads. Against main before #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, and aclose(). The integration tests are parametrized over the automatic backend and the explicit AnyIOBackend so the anyio path stays covered.

Checklist

  • I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • I've updated the documentation accordingly.

🤖 Generated with Claude Code

Review in cubic

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.
Comment thread src/httpcore2/httpcore2/_backends/asyncio.py Outdated
Comment thread src/httpcore2/httpcore2/_backends/asyncio.py Outdated
@veria-ai

veria-ai Bot commented Aug 27, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

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 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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)

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 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

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

Comment thread src/httpcore2/httpcore2/_backends/asyncio.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread tests/httpcore2/_sync/test_integration.py Outdated
Kludex added 2 commits August 27, 2026 08:38
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

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

Comment thread benchmark/client.py

def build_httpx(scenario: Scenario, payload: bytes) -> tuple[RequestFn, CloseFn]:
# The original httpx, for reference; it shares the httpx2 API.
import httpx

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: 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>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

1 participant