Skip to content

Commit d92b968

Browse files
authored
chore: migrate HTTP client dependency from httpx to httpx2 (#143)
The Streamable HTTP transport now builds on `httpx2` instead of `httpx`. httpx2 keeps the same public surface for the pieces we rely on (`AsyncClient`, `ASGITransport`, `MockTransport`, `Request`, `Response`, `AsyncByteStream`, `Timeout`), so the transport logic is unchanged — only the module name and the dependency move. - pyproject.toml: `httpx[http2]>=0.27` -> `httpx2[http2]>=2.12` in both the dev group and the `http` extra - src/acp/http/client.py: import and type/annotation references now point at httpx2 - src/acp/_cookies.py, src/acp/http/__init__.py: docstring references updated - tests/http/*: updated to httpx2 - docs/web-transport.md: dependency note updated - uv.lock: drop httpx 0.28.1 + httpcore 1.0.9, add httpx2 2.12.0 + httpcore2 2.12.0 (+ truststore) Note: starlette.testclient prefers httpx2 and only falls back to httpx with a deprecation warning, so this also removes that warning path. Verified: `uv lock --locked`, `prek run -a`, `ty check`, `deptry src`, and `pytest --doctest-modules` (256 passed, 1 skipped).
1 parent c1004f8 commit d92b968

10 files changed

Lines changed: 95 additions & 74 deletions

File tree

‎docs/web-transport.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Both reuse the existing JSON-RPC message format and ACP lifecycle
2121
pip install "agent-client-protocol[http]"
2222
```
2323

24-
This pulls in `httpx[http2]` (HTTP/2 + SSE consumption), `websockets`, and
24+
This pulls in `httpx2[http2]` (HTTP/2 + SSE consumption), `websockets`, and
2525
`starlette` (the server application). The core SDK and stdio transport do not
2626
require these optional dependencies.
2727

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ dev = [
4545
"mkdocstrings[python]>=0.26.1",
4646
"python-dotenv>=1.1.1",
4747
"prek>=0.2.17",
48-
"httpx[http2]>=0.27",
48+
"httpx2[http2]>=2.12",
4949
"websockets>=12.0",
5050
"uvicorn>=0.30",
5151
"starlette>=0.49.3",
@@ -54,7 +54,7 @@ dev = [
5454
[project.optional-dependencies]
5555
logfire = ["logfire>=0.14", "opentelemetry-sdk>=1.28.0"]
5656
# Experimental remote transports (Streamable HTTP + WebSocket), client + server.
57-
http = ["httpx[http2]>=0.27", "websockets>=12.0", "starlette>=0.49.3"]
57+
http = ["httpx2[http2]>=2.12", "websockets>=12.0", "starlette>=0.49.3"]
5858

5959
[build-system]
6060
requires = ["pdm-backend"]

‎src/acp/_cookies.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""In-memory cookie store for the WebSocket handshake.
22
3-
The HTTP client relies on ``httpx``'s built-in cookie jar for session affinity,
3+
The HTTP client relies on ``httpx2``'s built-in cookie jar for session affinity,
44
but the WebSocket handshake needs a small, explicit store to collect
55
``Set-Cookie`` headers from the upgrade response and echo them back as a
66
``Cookie`` request header for the socket lifetime.

‎src/acp/http/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Streamable HTTP transport for ACP (experimental).
22
33
Public exports are import-guarded: the heavy client/server implementations pull
4-
in optional dependencies (``httpx[http2]`` and ``starlette``). Importing a symbol without the
4+
in optional dependencies (``httpx2[http2]`` and ``starlette``). Importing a symbol without the
55
extra installed raises a friendly ``ImportError`` pointing at
66
``pip install agent-client-protocol[http]``.
77
"""

‎src/acp/http/client.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
)
3939

4040
try:
41-
import httpx
41+
import httpx2
4242
except ImportError as exc: # pragma: no cover - exercised via import guard message
4343
msg = "The Streamable HTTP transport requires the 'http' extra: pip install agent-client-protocol[http]"
4444
raise ImportError(msg) from exc
@@ -68,7 +68,7 @@ def __init__(
6868
self,
6969
url: str,
7070
*,
71-
client: httpx.AsyncClient,
71+
client: httpx2.AsyncClient,
7272
owns_client: bool,
7373
headers: dict[str, str] | None = None,
7474
) -> None:
@@ -191,7 +191,7 @@ async def _consume_stream(self, *, session_id: str | None) -> None:
191191
return
192192
async for event in parse_sse_stream(_aiter_raw(response)):
193193
self._handle_incoming(event)
194-
except (httpx.HTTPError, asyncio.CancelledError):
194+
except (httpx2.HTTPError, asyncio.CancelledError):
195195
return
196196
finally:
197197
self._on_stream_closed(session_id)
@@ -229,22 +229,22 @@ def _handle_incoming(self, message: dict[str, Any]) -> None:
229229
self._inbox.put_nowait(message)
230230

231231

232-
async def _aiter_raw(response: httpx.Response) -> AsyncIterator[bytes]:
232+
async def _aiter_raw(response: httpx2.Response) -> AsyncIterator[bytes]:
233233
async for chunk in response.aiter_bytes():
234234
yield chunk
235235

236236

237237
def create_http_stream(
238238
url: str,
239239
*,
240-
client: httpx.AsyncClient | None = None,
240+
client: httpx2.AsyncClient | None = None,
241241
headers: dict[str, str] | None = None,
242242
) -> Transport:
243243
"""Create a Streamable HTTP client :class:`Transport`.
244244
245245
Args:
246246
url: The ACP endpoint URL (e.g. ``https://host/acp``).
247-
client: An optional pre-configured ``httpx.AsyncClient``. If omitted, an
247+
client: An optional pre-configured ``httpx2.AsyncClient``. If omitted, an
248248
HTTP/2-enabled client with a cookie jar is created and owned by the
249249
transport (closed on ``close()``).
250250
headers: Extra headers sent on every request.
@@ -255,5 +255,5 @@ def create_http_stream(
255255
owns_client = client is None
256256
if client is None:
257257
# SSE GET streams are long-lived, so disable read timeouts by default.
258-
client = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(None))
258+
client = httpx2.AsyncClient(http2=True, timeout=httpx2.Timeout(None))
259259
return _HttpStreamTransport(url, client=client, owns_client=owns_client, headers=headers)

‎tests/http/test_asgi.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from contextlib import asynccontextmanager
88
from typing import Any
99

10-
import httpx
10+
import httpx2
1111
import pytest
1212
from starlette.applications import Starlette
1313
from starlette.routing import Mount
@@ -40,7 +40,7 @@ async def test_http_errors(method: str, headers: dict[str, str], body: str, stat
4040
app = create_asgi_app(lambda conn: TestAgent())
4141
async with (
4242
app.router.lifespan_context(app),
43-
httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client,
43+
httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client,
4444
):
4545
response = await client.request(method, "/acp", headers=headers, content=body)
4646
assert response.status_code == status
@@ -53,7 +53,7 @@ async def test_unsupported_methods_do_not_open_a_stream(method: str) -> None:
5353
app = create_asgi_app(lambda conn: TestAgent())
5454
async with (
5555
app.router.lifespan_context(app),
56-
httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client,
56+
httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client,
5757
):
5858
response = await client.request(method, "/acp")
5959
assert response.status_code == 405
@@ -82,7 +82,7 @@ async def lifespan(app: Starlette):
8282
prefix = "/agents" if mounted else ""
8383
path = prefix + (endpoint or "/acp")
8484
server = await serve_asgi(app)
85-
async with httpx.AsyncClient(base_url=f"http://{server.host}:{server.port}") as client:
85+
async with httpx2.AsyncClient(base_url=f"http://{server.host}:{server.port}") as client:
8686
response = await client.post(path, json=INITIALIZE)
8787
assert response.status_code == 200
8888
assert response.json()["id"] == 0
@@ -111,7 +111,7 @@ async def lifespan(app: Starlette):
111111
@pytest.mark.asyncio
112112
async def test_lifespan_closes_http_connections() -> None:
113113
app = create_asgi_app(lambda conn: TestAgent())
114-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
114+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client:
115115
async with app.router.lifespan_context(app):
116116
response = await client.post("/acp", json=INITIALIZE)
117117
connection_id = response.headers[CONNECTION_ID_HEADER]
@@ -130,7 +130,7 @@ def factory(conn):
130130

131131
app = create_asgi_app(factory, path=path)
132132
async with app.router.lifespan_context(app):
133-
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
133+
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client:
134134
response = await client.post(path, json=INITIALIZE)
135135
connection_id = response.headers[CONNECTION_ID_HEADER]
136136
scope = {

‎tests/http/test_fixes.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import json
1515
from typing import Any
1616

17-
import httpx
17+
import httpx2
1818
import pytest
1919

2020
import acp.http.server as server_mod
@@ -177,22 +177,22 @@ async def test_http_client_surfaces_eof_when_connection_stream_ends() -> None:
177177
"""When the connection-scoped SSE stream ends, receive() must return None."""
178178
conn_id = "conn-eof"
179179

180-
def handler(request: httpx.Request) -> httpx.Response:
180+
def handler(request: httpx2.Request) -> httpx2.Response:
181181
if request.method == "POST":
182182
body = json.loads(request.content)
183183
if body.get("method") == "initialize":
184-
return httpx.Response(
184+
return httpx2.Response(
185185
200,
186186
headers={CONNECTION_ID_HEADER: conn_id, "Content-Type": CONTENT_TYPE_JSON},
187187
json={"jsonrpc": "2.0", "id": body["id"], "result": {}},
188188
)
189-
return httpx.Response(202)
189+
return httpx2.Response(202)
190190
if request.method == "GET":
191191
# SSE stream that immediately ends (empty body -> EOF).
192-
return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"")
193-
return httpx.Response(202)
192+
return httpx2.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"")
193+
return httpx2.Response(202)
194194

195-
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
195+
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
196196
transport = create_http_stream("http://testserver/acp", client=client)
197197
try:
198198
await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})

‎tests/http/test_http_client.py‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
from typing import Any
88

9-
import httpx
9+
import httpx2
1010
import pytest
1111

1212
from acp._sse import serialize_sse_event
@@ -17,7 +17,7 @@
1717

1818

1919
class FakeServer:
20-
"""A minimal in-memory Streamable HTTP server backed by httpx.MockTransport."""
20+
"""A minimal in-memory Streamable HTTP server backed by httpx2.MockTransport."""
2121

2222
def __init__(self) -> None:
2323
self.posts: list[dict[str, Any]] = []
@@ -26,28 +26,28 @@ def __init__(self) -> None:
2626
self.conn_stream: asyncio.Queue[bytes | None] = asyncio.Queue()
2727
self.session_streams: dict[str, asyncio.Queue[bytes | None]] = {}
2828

29-
def handler(self, request: httpx.Request) -> httpx.Response:
29+
def handler(self, request: httpx2.Request) -> httpx2.Response:
3030
if request.method == "POST":
3131
return self._handle_post(request)
3232
if request.method == "GET":
3333
return self._handle_get(request)
3434
if request.method == "DELETE":
3535
self.deleted = True
36-
return httpx.Response(202)
37-
return httpx.Response(405)
36+
return httpx2.Response(202)
37+
return httpx2.Response(405)
3838

39-
def _handle_post(self, request: httpx.Request) -> httpx.Response:
39+
def _handle_post(self, request: httpx2.Request) -> httpx2.Response:
4040
body = json.loads(request.content)
4141
self.posts.append(body)
4242
if body.get("method") == "initialize":
43-
return httpx.Response(
43+
return httpx2.Response(
4444
200,
4545
headers={CONNECTION_ID_HEADER: CONN_ID, "Content-Type": CONTENT_TYPE_JSON},
4646
json={"jsonrpc": "2.0", "id": body["id"], "result": {"protocolVersion": 1}},
4747
)
48-
return httpx.Response(202)
48+
return httpx2.Response(202)
4949

50-
def _handle_get(self, request: httpx.Request) -> httpx.Response:
50+
def _handle_get(self, request: httpx2.Request) -> httpx2.Response:
5151
session_id = request.headers.get(SESSION_ID_HEADER)
5252
if session_id is not None:
5353
queue = self.session_streams.setdefault(session_id, asyncio.Queue())
@@ -61,7 +61,7 @@ async def body() -> Any:
6161
return
6262
yield chunk
6363

64-
return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, stream=_AsyncByteStream(body()))
64+
return httpx2.Response(200, headers={"Content-Type": "text/event-stream"}, stream=_AsyncByteStream(body()))
6565

6666
def push_conn(self, message: dict[str, Any]) -> None:
6767
self.conn_stream.put_nowait(serialize_sse_event(message))
@@ -71,7 +71,7 @@ def push_session(self, session_id: str, message: dict[str, Any]) -> None:
7171
queue.put_nowait(serialize_sse_event(message))
7272

7373

74-
class _AsyncByteStream(httpx.AsyncByteStream):
74+
class _AsyncByteStream(httpx2.AsyncByteStream):
7575
def __init__(self, iterator: Any) -> None:
7676
self._iterator = iterator
7777

@@ -81,7 +81,7 @@ async def __aiter__(self) -> Any:
8181

8282

8383
def _make_transport(server: FakeServer):
84-
client = httpx.AsyncClient(transport=httpx.MockTransport(server.handler))
84+
client = httpx2.AsyncClient(transport=httpx2.MockTransport(server.handler))
8585
return create_http_stream("http://testserver/acp", client=client), client
8686

8787

@@ -102,10 +102,10 @@ async def test_initialize_posts_and_reads_connection_id() -> None:
102102

103103
@pytest.mark.asyncio
104104
async def test_initialize_failure_raises() -> None:
105-
def handler(request: httpx.Request) -> httpx.Response:
106-
return httpx.Response(500)
105+
def handler(request: httpx2.Request) -> httpx2.Response:
106+
return httpx2.Response(500)
107107

108-
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
108+
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
109109
transport = create_http_stream("http://testserver/acp", client=client)
110110
try:
111111
with pytest.raises(AcpHttpStatusError) as exc:
@@ -188,19 +188,19 @@ async def test_close_deletes_connection() -> None:
188188

189189
@pytest.mark.asyncio
190190
async def test_post_error_status_raises() -> None:
191-
def handler(request: httpx.Request) -> httpx.Response:
191+
def handler(request: httpx2.Request) -> httpx2.Response:
192192
if request.method == "POST":
193193
body = json.loads(request.content)
194194
if body.get("method") == "initialize":
195-
return httpx.Response(
195+
return httpx2.Response(
196196
200,
197197
headers={CONNECTION_ID_HEADER: CONN_ID},
198198
json={"jsonrpc": "2.0", "id": body["id"], "result": {}},
199199
)
200-
return httpx.Response(404)
201-
return httpx.Response(200, headers={"Content-Type": "text/event-stream"})
200+
return httpx2.Response(404)
201+
return httpx2.Response(200, headers={"Content-Type": "text/event-stream"})
202202

203-
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
203+
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
204204
transport = create_http_stream("http://testserver/acp", client=client)
205205
try:
206206
await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})

‎tests/http/test_loopback.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""End-to-end in-process loopback tests: Python client transport <-> ASGI server.
22
3-
Boots the ASGI app under a real uvicorn server (httpx's ASGITransport buffers
3+
Boots the ASGI app under a real uvicorn server (httpx2's ASGITransport buffers
44
whole responses and cannot consume infinite SSE streams), then drives the full
55
ACP flow over both the Streamable HTTP and WebSocket transports.
66
"""

0 commit comments

Comments
 (0)