Skip to content

Add httpunk-backed async transport - #1089

Open
Kludex wants to merge 1 commit into
mainfrom
add-httpunk-transport
Open

Add httpunk-backed async transport#1089
Kludex wants to merge 1 commit into
mainfrom
add-httpunk-transport

Conversation

@Kludex

@Kludex Kludex commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Adds an optional async HTTP/1.1 transport backed by httpunk:

  • httpx2.AsyncHTTPunkTransport
  • httpx2[httpunk] extra

This is intentionally small and experimental: it supports async HTTP/1.1 with a simple per-origin connection pool.

Test plan

  • uv run --with httpunk ruff check src/httpx2/httpx2/_transports/httpunk.py tests/httpx2/test_httpunk_transport.py
  • uv run --with httpunk pytest tests/httpx2/test_httpunk_transport.py -q

@github-actions

Copy link
Copy Markdown

@codspeed-hq

codspeed-hq Bot commented Jul 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 15 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing add-httpunk-transport (6c16227) with main (8cff429)

Open in CodSpeed

Footnotes

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

@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: 6c16227c35

ℹ️ 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".

@@ -0,0 +1,190 @@
from __future__ import annotations

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 Record the commit under the engineer's authorship

The reviewed commit records Codex <codex@openai.com> as its Author rather than the engineer, contrary to the repository requirement that coding-agent work retain engineer-only authorship; recreate the commit with the engineer as its sole author.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.


import httpx2

pytest.importorskip("httpunk")

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 Install httpunk in the CI test environment

Checked .github/workflows/main.yml: each test job runs scripts/install, which executes uv sync --frozen, but the root dev group requests the httpx2 extras without httpunk. Consequently this module-level skip fires on every CI Python version, none of these tests run, and the imported httpunk.py remains largely uncovered before scripts/coverage enforces 100% coverage. Include the extra in the development/CI environment rather than silently skipping the new transport tests.

Useful? React with 👍 / 👎.

Comment on lines +126 to +127
pool.count += 1
connection = await self._connect(origin)

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 Roll back capacity when connection setup fails

With max_connections=1, or once every slot has encountered a transient DNS, TCP, or TLS failure, _connect raises after pool.count is incremented and the count is never restored. The next request then sees the pool at capacity and waits forever on an empty idle queue. Roll back the reserved slot on every setup failure or cancellation.

Useful? React with 👍 / 👎.

return
with contextlib.suppress(ValueError):
pool.all_connections.remove(connection)
pool.count -= 1

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 Wake queued acquires after discarding a connection

When the pool is saturated and another request is already blocked in pool.idle.get(), an error on the active request calls _discard and only decrements the count. The queued request is never notified that capacity is now available, so it can hang indefinitely even though pool.count < pool.max_connections; use a condition/semaphore or otherwise wake the waiter so it can create a replacement connection.

Useful? React with 👍 / 👎.

Comment on lines +145 to +149
_transport, protocol = await loop.create_connection(
lambda: httpunk.asyncio.H1ClientProtocol(authority=authority),
host,
port,
ssl=ssl_context,

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 Map socket setup failures to ConnectError

When DNS resolution, TCP connection, or TLS negotiation fails, loop.create_connection raises raw exceptions such as OSError or ssl.SSLError. Because this path performs no exception translation, callers using the documented except httpx2.RequestError hierarchy will not catch these transport failures. Convert setup failures to httpx2.ConnectError or the appropriate transport exception.

Useful? React with 👍 / 👎.

connection = await self._acquire(origin)

target = request.url.raw_path.decode("ascii") or "/"
body = await request.aread()

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 Release the connection when reading the request body fails

When an asynchronous upload stream raises or the task is cancelled during request.aread(), the connection has already been acquired but this await occurs outside the cleanup block. That connection remains counted and is never returned to the idle queue, so a small pool can block all subsequent requests. Read the body before acquiring a connection or include this operation in cleanup that releases the slot.

Useful? React with 👍 / 👎.

@Kludex

Kludex commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

I think we may want httpx2-httpunk instead of being in the transports path.

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

5 issues found across 6 files

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="src/httpx2/httpx2/_transports/httpunk.py">

<violation number="1" location="src/httpx2/httpx2/_transports/httpunk.py:94">
P1: `request.aread()` is called after acquiring the connection but before the `try/except` block that calls `_discard`. If the body read raises (e.g. stream error or cancellation), the acquired connection is never released or discarded, permanently leaking a pool slot. Move `aread()` before `_acquire`, or include it inside the try block.</violation>

<violation number="2" location="src/httpx2/httpx2/_transports/httpunk.py:127">
P1: If `_connect` raises (e.g. DNS resolution failure, TCP timeout, TLS error), `pool.count` has already been incremented but is never decremented. With `max_connections=1` this permanently exhausts the pool, causing all subsequent requests to hang indefinitely on `pool.idle.get()`. Wrap the connect call in a try/except that decrements `pool.count` on failure.</violation>

<violation number="3" location="src/httpx2/httpx2/_transports/httpunk.py:131">
P2: When the connection pool is exhausted (all `max_connections` in use), `pool.idle.get()` blocks indefinitely without any timeout. Unlike the default transport which supports pool timeouts via the `"timeout"` extension, this transport offers no back-pressure or timeout mechanism, so callers can hang forever waiting for a connection to become available. Consider adding an `asyncio.wait_for` around the `get()` call, or reading a timeout from `request.extensions`.</violation>

<violation number="4" location="src/httpx2/httpx2/_transports/httpunk.py:145">
P1: `loop.create_connection` raises raw `OSError` or `ssl.SSLError` on DNS/TCP/TLS failures. These are not mapped to `httpx2.ConnectError`, so callers using `except httpx2.RequestError` (or `except httpx2.ConnectError`) won't catch transport setup failures. Wrap this call and translate connection-related exceptions to `ConnectError`.</violation>

<violation number="5" location="src/httpx2/httpx2/_transports/httpunk.py:182">
P1: After decrementing `pool.count`, tasks blocked on `await pool.idle.get()` in `_acquire` are never woken. When the pool is saturated and a connection error triggers `_discard`, waiters hang indefinitely despite capacity becoming available. A sentinel value should be placed on the queue, or a semaphore/condition should be used to notify waiters that they can create a new connection.</violation>
</file>

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

Re-trigger cubic

Comment on lines +127 to +129
connection = await self._connect(origin)
pool.all_connections.append(connection)
return connection

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.

P1: If _connect raises (e.g. DNS resolution failure, TCP timeout, TLS error), pool.count has already been incremented but is never decremented. With max_connections=1 this permanently exhausts the pool, causing all subsequent requests to hang indefinitely on pool.idle.get(). Wrap the connect call in a try/except that decrements pool.count on failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/httpunk.py, line 127:

<comment>If `_connect` raises (e.g. DNS resolution failure, TCP timeout, TLS error), `pool.count` has already been incremented but is never decremented. With `max_connections=1` this permanently exhausts the pool, causing all subsequent requests to hang indefinitely on `pool.idle.get()`. Wrap the connect call in a try/except that decrements `pool.count` on failure.</comment>

<file context>
@@ -0,0 +1,190 @@
+        async with pool.lock:
+            if pool.count < pool.max_connections:
+                pool.count += 1
+                connection = await self._connect(origin)
+                pool.all_connections.append(connection)
+                return connection
</file context>
Suggested change
connection = await self._connect(origin)
pool.all_connections.append(connection)
return connection
try:
connection = await self._connect(origin)
except BaseException:
pool.count -= 1
raise
pool.all_connections.append(connection)
return connection

connection = await self._acquire(origin)

target = request.url.raw_path.decode("ascii") or "/"
body = await request.aread()

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.

P1: request.aread() is called after acquiring the connection but before the try/except block that calls _discard. If the body read raises (e.g. stream error or cancellation), the acquired connection is never released or discarded, permanently leaking a pool slot. Move aread() before _acquire, or include it inside the try block.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/httpunk.py, line 94:

<comment>`request.aread()` is called after acquiring the connection but before the `try/except` block that calls `_discard`. If the body read raises (e.g. stream error or cancellation), the acquired connection is never released or discarded, permanently leaking a pool slot. Move `aread()` before `_acquire`, or include it inside the try block.</comment>

<file context>
@@ -0,0 +1,190 @@
+        connection = await self._acquire(origin)
+
+        target = request.url.raw_path.decode("ascii") or "/"
+        body = await request.aread()
+
+        try:
</file context>

Comment on lines +145 to +150
_transport, protocol = await loop.create_connection(
lambda: httpunk.asyncio.H1ClientProtocol(authority=authority),
host,
port,
ssl=ssl_context,
)

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.

P1: loop.create_connection raises raw OSError or ssl.SSLError on DNS/TCP/TLS failures. These are not mapped to httpx2.ConnectError, so callers using except httpx2.RequestError (or except httpx2.ConnectError) won't catch transport setup failures. Wrap this call and translate connection-related exceptions to ConnectError.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/httpunk.py, line 145:

<comment>`loop.create_connection` raises raw `OSError` or `ssl.SSLError` on DNS/TCP/TLS failures. These are not mapped to `httpx2.ConnectError`, so callers using `except httpx2.RequestError` (or `except httpx2.ConnectError`) won't catch transport setup failures. Wrap this call and translate connection-related exceptions to `ConnectError`.</comment>

<file context>
@@ -0,0 +1,190 @@
+        authority = f"{host}:{port}"
+
+        loop = asyncio.get_running_loop()
+        _transport, protocol = await loop.create_connection(
+            lambda: httpunk.asyncio.H1ClientProtocol(authority=authority),
+            host,
</file context>
Suggested change
_transport, protocol = await loop.create_connection(
lambda: httpunk.asyncio.H1ClientProtocol(authority=authority),
host,
port,
ssl=ssl_context,
)
try:
_transport, protocol = await loop.create_connection(
lambda: httpunk.asyncio.H1ClientProtocol(authority=authority),
host,
port,
ssl=ssl_context,
)
except (OSError, ssl.SSLError) as exc:
from .._exceptions import ConnectError
msg = f"Failed to connect to {host}:{port}"
raise ConnectError(msg) from exc

return
with contextlib.suppress(ValueError):
pool.all_connections.remove(connection)
pool.count -= 1

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.

P1: After decrementing pool.count, tasks blocked on await pool.idle.get() in _acquire are never woken. When the pool is saturated and a connection error triggers _discard, waiters hang indefinitely despite capacity becoming available. A sentinel value should be placed on the queue, or a semaphore/condition should be used to notify waiters that they can create a new connection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/httpunk.py, line 182:

<comment>After decrementing `pool.count`, tasks blocked on `await pool.idle.get()` in `_acquire` are never woken. When the pool is saturated and a connection error triggers `_discard`, waiters hang indefinitely despite capacity becoming available. A sentinel value should be placed on the queue, or a semaphore/condition should be used to notify waiters that they can create a new connection.</comment>

<file context>
@@ -0,0 +1,190 @@
+            return
+        with contextlib.suppress(ValueError):
+            pool.all_connections.remove(connection)
+        pool.count -= 1
+
+    async def aclose(self) -> None:
</file context>

pool.all_connections.append(connection)
return connection

return await pool.idle.get()

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 the connection pool is exhausted (all max_connections in use), pool.idle.get() blocks indefinitely without any timeout. Unlike the default transport which supports pool timeouts via the "timeout" extension, this transport offers no back-pressure or timeout mechanism, so callers can hang forever waiting for a connection to become available. Consider adding an asyncio.wait_for around the get() call, or reading a timeout from request.extensions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpx2/httpx2/_transports/httpunk.py, line 131:

<comment>When the connection pool is exhausted (all `max_connections` in use), `pool.idle.get()` blocks indefinitely without any timeout. Unlike the default transport which supports pool timeouts via the `"timeout"` extension, this transport offers no back-pressure or timeout mechanism, so callers can hang forever waiting for a connection to become available. Consider adding an `asyncio.wait_for` around the `get()` call, or reading a timeout from `request.extensions`.</comment>

<file context>
@@ -0,0 +1,190 @@
+                pool.all_connections.append(connection)
+                return connection
+
+        return await pool.idle.get()
+
+    async def _connect(self, origin: tuple[str, str, int]) -> _HTTPunkConnection:
</file context>

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