Replace httpx and httpx-sse with httpx2 (#2972)
Some checks failed
CI / checks (push) Has been cancelled
CI / all-green (push) Has been cancelled

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-14 18:05:08 +02:00 • committed by GitHub
commit 2713b53b12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,6 +1,6 @@
"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx.
"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx2.
`httpx.ASGITransport` runs the application to completion and only then hands the buffered
`httpx2.ASGITransport` runs the application to completion and only then hands the buffered
response to the caller, so a server that streams its response — the streamable HTTP transport's
SSE responses — can never converse with the client mid-request: a server-initiated request
nested inside a still-open call deadlocks. `StreamingASGITransport` removes that limitation by
@ -20,7 +20,7 @@ The behavioural contract, pinned by `test_bridge.py`:
server over a real socket would give.
The transport owns an anyio task group for the application tasks; it is opened and closed by
`httpx.AsyncClient`'s own context manager, so use the client as a context manager (the suite
`httpx2.AsyncClient`'s own context manager, so use the client as a context manager (the suite
always does). Closing the transport cancels every running application task by default; set
`cancel_on_close=False` to wait for the application's own disconnect handling instead.
"""
@ -31,14 +31,14 @@ from types import TracebackType
import anyio
import anyio.abc
import httpx
import httpx2
from anyio.streams.memory import MemoryObjectReceiveStream
from starlette.types import ASGIApp, Message, Scope
from mcp.shared._compat import resync_tracer
class _StreamingResponseBody(httpx.AsyncByteStream):
class _StreamingResponseBody(httpx2.AsyncByteStream):
"""A response body that yields chunks as the application produces them.
Closing it tells the application the client has gone away (`http.disconnect`), mirroring a
@ -58,7 +58,7 @@ class _StreamingResponseBody(httpx.AsyncByteStream):
await self._chunks.aclose()
class StreamingASGITransport(httpx.AsyncBaseTransport):
class StreamingASGITransport(httpx2.AsyncBaseTransport):
"""Drive an ASGI application in-process, streaming each response as it is produced.
With `cancel_on_close` (the default), closing the transport cancels every application task
@ -84,7 +84,7 @@ class StreamingASGITransport(httpx.AsyncBaseTransport):
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
) -> None:
# httpx closes every streamed response before closing the transport, so by now each
# httpx2 closes every streamed response before closing the transport, so by now each
# application task has been delivered `http.disconnect`. Either cancel immediately, or wait
# for the application's own disconnect handling to unwind.
if self._cancel_on_close:
@ -92,8 +92,8 @@ class StreamingASGITransport(httpx.AsyncBaseTransport):
await self._task_group.__aexit__(exc_type, exc_value, traceback)
await resync_tracer()
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
assert isinstance(request.stream, httpx.AsyncByteStream)
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
assert isinstance(request.stream, httpx2.AsyncByteStream)
request_body = b"".join([chunk async for chunk in request.stream])
scope: Scope = {
@ -164,7 +164,7 @@ class StreamingASGITransport(httpx.AsyncBaseTransport):
client_disconnected.set()
await chunk_reader.aclose()
raise
return httpx.Response(
return httpx2.Response(
status_code=response_status,
headers=response_headers,
stream=_StreamingResponseBody(chunk_reader, client_disconnected),