Skip to content

Update vendored httpx-ws to upstream v0.9.0#1067

Merged
Kludex merged 1 commit into
mainfrom
update-vendored-httpx-ws
Jul 14, 2026
Merged

Update vendored httpx-ws to upstream v0.9.0#1067
Kludex merged 1 commit into
mainfrom
update-vendored-httpx-ws

Conversation

@Kludex

@Kludex Kludex commented Jul 14, 2026

Copy link
Copy Markdown
Member

Closes the gap reported in #1066: the vendoring in #1042 accidentally used frankie567/httpx-ws@6bc568b (v0.6.2 era, October 2024). This ports every upstream change from there to the current tip, frankie567/httpx-ws@0341891 (v0.9.0 + 6 commits) - 80 commits, of which 38 touch library or test code; the rest are upstream CI/docs/tooling.

What comes in

  • Write lock serializing stream writes (httpx-ws#29)
  • Async session rebuilt on anyio.AsyncContextManagerMixin, fixing the asyncio hang on exit after cancellation and cancel scope corruption (httpx-ws#108); anyio>=4.10 is now required
  • ASGIWebSocketTransport rewritten on a task group and anyio streams instead of a blocking portal and queue.Queue
  • WebSocket Denial Response ASGI extension support
  • Configurable initial_receive_timeout with a clear error when the server never calls accept()
  • TimeoutError instead of queue.Empty on sync receive timeout (docs updated)
  • Proper end-of-stream handling and keepalive ping exit when the connection is closing
  • Class-based WebSocketClient/AsyncWebSocketClient with session_class support, exported from httpx2.websockets

Deliberate deviations from upstream

  • httpx2 adaptations preserved: relative imports, lazy httpcore2 imports, explicit handshake parameters instead of **kwargs, Client.websocket() as the public entry point (connect_ws/aconnect_ws stay unexported)
  • session_class lives only on the client classes, not on connect_ws/aconnect_ws: mypy cannot infer the PEP 696 TypeVar default through the contextmanager decorator
  • Two fixes upstream does not have (worth PRing back): aclose is registered before the initial receive so a never-accepting app does not leak memory object streams, and the double-__aenter__ guard is covered by a test

Verification

  • 164 websocket tests pass on asyncio and trio, 3 consecutive full runs; whole httpx2 suite: 1634 passed
  • 100% coverage on all websockets/ files; ruff format, ruff check, mypy strict, unasync all clean
  • Independently reviewed by Codex against the full upstream commit range: all 38 code commits verified present, no porting bugs found

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Review in cubic

The initial vendoring in #1042 accidentally used frankie567/httpx-ws@6bc568b
(v0.6.2 era, October 2024), missing 80 upstream commits. This ports every
upstream change from that point to the current tip, frankie567/httpx-ws@0341891
(v0.9.0 + 6 commits). See discussion #1066.

Highlights:
- Serialize stream writes with a write lock (httpx-ws#29)
- Rebuild the async session on anyio.AsyncContextManagerMixin, fixing the
  asyncio hang on exit after cancellation and cancel scope corruption
  (httpx-ws#108); requires anyio>=4.10
- Rewrite ASGIWebSocketTransport on a task group and anyio streams instead of
  a blocking portal and queue.Queue
- Support the WebSocket Denial Response ASGI extension
- Add a configurable initial_receive_timeout with a clear error when the
  server never accepts
- Raise TimeoutError instead of queue.Empty on sync receive timeout
- Handle end of stream and closing-state keepalive pings properly
- Add class-based WebSocketClient/AsyncWebSocketClient with session_class
  support (kept off connect_ws/aconnect_ws, where mypy cannot infer the
  TypeVar default through the contextmanager decorator)

Two fixes not in upstream: register aclose before the initial receive so a
never-accepting app does not leak memory object streams, and cover the
double-__aenter__ guard with a test.
@github-actions

Copy link
Copy Markdown

Docs preview:

@codspeed-hq

codspeed-hq Bot commented Jul 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 15 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing update-vendored-httpx-ws (e87578d) with main (95aebc3)

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


P2 Badge Advertise the denial-response extension

The transport now consumes websocket.http.response.* messages, but the ASGI scope built here never includes extensions={'websocket.http.response': {}}. ASGI apps or frameworks that check the scope before using the denial-response extension will conclude it is unsupported and won't send the response, so this only works for apps that emit raw extension messages unconditionally.

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

Comment on lines 272 to +273
self.scope = scope
self.exit_stack = contextlib.AsyncExitStack()
stream, accept_response = await self.exit_stack.enter_async_context(
ASGIWebSocketAsyncNetworkStream(self.app, self.scope) # type: ignore[arg-type]
)

stream, accept_response = await self._task_group.start(self._create_asgi_websocket_async_network_stream)

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 Pass the scope through the task start

When two websocket requests share one ASGIWebSocketTransport, this stores per-request state on the transport and the task started on the next line reads self.scope later. A second request can overwrite it before the first _create_asgi_websocket_async_network_stream constructs its stream, so the first connection can be delivered to the wrong path, headers, or subprotocols. Pass scope into the started helper instead of using mutable transport state.

Useful? React with 👍 / 👎.

ASGIWebSocketAsyncNetworkStream(self.app, self.scope) # type: ignore[arg-type]
)

stream, accept_response = await self._task_group.start(self._create_asgi_websocket_async_network_stream)

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 Initialize the task group outside context-managed clients

This path now assumes __aenter__ has run and created self._task_group. If a caller uses the normal AsyncClient lifecycle without async with (create client, call aconnect_ws, then aclose()), the websocket request reaches this line with no _task_group attribute and fails before the app is called; the previous transport created its per-request stream state here. Either lazily create and close the task group or keep an aclose path for non-context-managed clients.

Useful? React with 👍 / 👎.

@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 9 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/websockets/_transport.py">

<violation number="1" location="src/httpx2/httpx2/websockets/_transport.py:88">
P2: Denial response body collection loop lacks a receive timeout, so a server that starts a denial response but never completes the body will cause the client to hang indefinitely. Consider applying a timeout to the inner `receive()` calls (reusing `initial_receive_timeout` or a separate configurable value) so that a stalled denial-response body surfaces a `TimeoutError` / `RuntimeError` rather than hanging forever.</violation>

<violation number="2" location="src/httpx2/httpx2/websockets/_transport.py:91">
P2: Valid denial responses that omit `body` (for example, a header-only response) now fail with `KeyError` instead of producing a `WebSocketUpgradeError`. Reading the field with an empty-bytes default preserves the ASGI behavior.</violation>

<violation number="3" location="src/httpx2/httpx2/websockets/_transport.py:221">
P2: Calling `await client.aclose()` leaves the transport task group and all active WebSocket stream queues open, so applications that close clients explicitly leak connection resources. A shared, idempotent `aclose()` implementation should close `_exit_stack`, with `__aexit__` preserving exception-aware exit handling.</violation>

<violation number="4" location="src/httpx2/httpx2/websockets/_transport.py:273">
P2: Using `ASGIWebSocketTransport` with the supported `client = AsyncClient(...); await client.get(...)` pattern now raises `AttributeError` because `_task_group` has not been initialized. The transport needs a lazy/explicit lifecycle path that works for requests made without first entering the transport context.</violation>
</file>

<file name="tests/httpx2/websockets/test_api.py">

<violation number="1" location="tests/httpx2/websockets/test_api.py:855">
P3: The sync portion of `test_concurrency_write` submits `send_text` calls to a `ThreadPoolExecutor` but never checks the returned futures for exceptions. If any concurrent write fails, the exception is captured in the Future but silently discarded when the executor shuts down, so the test would pass despite a failure. The async half uses `anyio.create_task_group()` which correctly propagates exceptions; the sync half should either collect and `.result()` on each future, or use a mechanism that surfaces failures.</violation>
</file>

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

Re-trigger cubic

exc_val: BaseException | None = None,
exc_tb: TracebackType | None = None,
) -> None:
await super().__aexit__(exc_type, exc_val, exc_tb)

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: Calling await client.aclose() leaves the transport task group and all active WebSocket stream queues open, so applications that close clients explicitly leak connection resources. A shared, idempotent aclose() implementation should close _exit_stack, with __aexit__ preserving exception-aware exit handling.

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

<comment>Calling `await client.aclose()` leaves the transport task group and all active WebSocket stream queues open, so applications that close clients explicitly leak connection resources. A shared, idempotent `aclose()` implementation should close `_exit_stack`, with `__aexit__` preserving exception-aware exit handling.</comment>

<file context>
@@ -159,9 +199,28 @@ def __init__(
+        exc_val: BaseException | None = None,
+        exc_tb: TracebackType | None = None,
+    ) -> None:
+        await super().__aexit__(exc_type, exc_val, exc_tb)
+        assert self._exit_stack is not None
+        await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
</file context>

while True:
message = await self.receive()
assert message["type"] == "websocket.http.response.body"
body.append(message["body"])

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: Valid denial responses that omit body (for example, a header-only response) now fail with KeyError instead of producing a WebSocketUpgradeError. Reading the field with an empty-bytes default preserves the ASGI behavior.

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

<comment>Valid denial responses that omit `body` (for example, a header-only response) now fail with `KeyError` instead of producing a `WebSocketUpgradeError`. Reading the field with an empty-bytes default preserves the ASGI behavior.</comment>

<file context>
@@ -36,33 +36,77 @@ def __init__(self, event: wsproto.events.Event) -> None:
+                while True:
+                    message = await self.receive()
+                    assert message["type"] == "websocket.http.response.body"
+                    body.append(message["body"])
+                    if not message.get("more_body", False):
+                        break
</file context>
Suggested change
body.append(message["body"])
body.append(message.get("body", b""))

ASGIWebSocketAsyncNetworkStream(self.app, self.scope) # type: ignore[arg-type]
)

stream, accept_response = await self._task_group.start(self._create_asgi_websocket_async_network_stream)

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: Using ASGIWebSocketTransport with the supported client = AsyncClient(...); await client.get(...) pattern now raises AttributeError because _task_group has not been initialized. The transport needs a lazy/explicit lifecycle path that works for requests made without first entering the transport context.

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

<comment>Using `ASGIWebSocketTransport` with the supported `client = AsyncClient(...); await client.get(...)` pattern now raises `AttributeError` because `_task_group` has not been initialized. The transport needs a lazy/explicit lifecycle path that works for requests made without first entering the transport context.</comment>

<file context>
@@ -196,11 +270,7 @@ async def _handle_ws_request(
-            ASGIWebSocketAsyncNetworkStream(self.app, self.scope)  # type: ignore[arg-type]
-        )
-
+        stream, accept_response = await self._task_group.start(self._create_asgi_websocket_async_network_stream)
         accept_response_lines = accept_response.decode("utf-8").splitlines()
         headers = [
</file context>

status_code: int = message["status"]
headers: list[tuple[bytes, bytes]] = message["headers"]
body: list[bytes] = []
while True:

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: Denial response body collection loop lacks a receive timeout, so a server that starts a denial response but never completes the body will cause the client to hang indefinitely. Consider applying a timeout to the inner receive() calls (reusing initial_receive_timeout or a separate configurable value) so that a stalled denial-response body surfaces a TimeoutError / RuntimeError rather than hanging forever.

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

<comment>Denial response body collection loop lacks a receive timeout, so a server that starts a denial response but never completes the body will cause the client to hang indefinitely. Consider applying a timeout to the inner `receive()` calls (reusing `initial_receive_timeout` or a separate configurable value) so that a stalled denial-response body surfaces a `TimeoutError` / `RuntimeError` rather than hanging forever.</comment>

<file context>
@@ -36,33 +36,77 @@ def __init__(self, event: wsproto.events.Event) -> None:
+                status_code: int = message["status"]
+                headers: list[tuple[bytes, bytes]] = message["headers"]
+                body: list[bytes] = []
+                while True:
+                    message = await self.receive()
+                    assert message["type"] == "websocket.http.response.body"
</file context>

# Added for completeness, but were not able to reproduce the issue with the sync client
with httpx.Client(transport=httpx.HTTPTransport(uds=socket)) as client:
with connect_ws("http://socket/ws", client) as ws:
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:

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.

P3: The sync portion of test_concurrency_write submits send_text calls to a ThreadPoolExecutor but never checks the returned futures for exceptions. If any concurrent write fails, the exception is captured in the Future but silently discarded when the executor shuts down, so the test would pass despite a failure. The async half uses anyio.create_task_group() which correctly propagates exceptions; the sync half should either collect and .result() on each future, or use a mechanism that surfaces failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/httpx2/websockets/test_api.py, line 855:

<comment>The sync portion of `test_concurrency_write` submits `send_text` calls to a `ThreadPoolExecutor` but never checks the returned futures for exceptions. If any concurrent write fails, the exception is captured in the Future but silently discarded when the executor shuts down, so the test would pass despite a failure. The async half uses `anyio.create_task_group()` which correctly propagates exceptions; the sync half should either collect and `.result()` on each future, or use a mechanism that surfaces failures.</comment>

<file context>
@@ -838,3 +830,55 @@ def wait_for_session_threads(expected: int) -> None:
+        # Added for completeness, but were not able to reproduce the issue with the sync client
+        with httpx.Client(transport=httpx.HTTPTransport(uds=socket)) as client:
+            with connect_ws("http://socket/ws", client) as ws:
+                with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
+                    for _ in range(10):
+                        executor.submit(ws.send_text, "CLIENT_MESSAGE")
</file context>

@Kludex
Kludex merged commit c4e73cf into main Jul 14, 2026
15 checks passed
@Kludex
Kludex deleted the update-vendored-httpx-ws branch July 14, 2026 20:34
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