fix: repair httpx2/params/transport upgrade fallout

This commit is contained in:
lda
2026-09-06 11:34:01 +07:00 Verified
parent b628a5aa11
commit 28017648f6
6 changed files with 52 additions and 50 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from json import JSONDecodeError from json import JSONDecodeError
from typing import Any, Literal, TypeVar from typing import Any, Literal, TypeVar
import httpx import httpx2
from wf_api.models import ( from wf_api.models import (
CapabilityCallResult, CapabilityCallResult,
@@ -99,7 +99,7 @@ class PublicErrorWorkflowClientPort:
if known is not None: if known is not None:
raise known from exc raise known from exc
raise ProtocolError(exc.code, exc.message, exc.data) from exc raise ProtocolError(exc.code, exc.message, exc.data) from exc
except (httpx.TransportError, httpx.HTTPStatusError, JSONDecodeError) as exc: except (httpx2.HTTPError, JSONDecodeError) as exc:
raise TransportError(f"{operation} transport failed: {exc}") from exc raise TransportError(f"{operation} transport failed: {exc}") from exc
except RuntimeError as exc: except RuntimeError as exc:
# The RPC transport uses RuntimeError only when a decoded JSON-RPC # The RPC transport uses RuntimeError only when a decoded JSON-RPC
+7 -7
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from json import JSONDecodeError from json import JSONDecodeError
from typing import Any from typing import Any
import httpx import httpx2
from openapi_core import OpenAPI from openapi_core import OpenAPI
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -44,7 +44,7 @@ async def call_openapi_operation(
config: OpenApiExecutionConfig, config: OpenApiExecutionConfig,
payload: dict[str, Any], payload: dict[str, Any],
*, *,
client: httpx.AsyncClient | None = None, client: httpx2.AsyncClient | None = None,
) -> NodeReturn[OpenApiOperationOutput]: ) -> NodeReturn[OpenApiOperationOutput]:
"""Execute one raw OpenAPI operation through generic HTTP machinery.""" """Execute one raw OpenAPI operation through generic HTTP machinery."""
request = build_http_request_parts( request = build_http_request_parts(
@@ -65,11 +65,11 @@ async def call_openapi_operation(
) )
close_client = client is None close_client = client is None
active_client = client or httpx.AsyncClient(timeout=config.timeout_seconds) active_client = client or httpx2.AsyncClient(timeout=config.timeout_seconds)
try: try:
try: try:
response = await _send_request(active_client, request) response = await _send_request(active_client, request)
except httpx.HTTPError as exc: except httpx2.HTTPError as exc:
return NodeReturn( return NodeReturn(
outcome="transport_error", outcome="transport_error",
output=OpenApiOperationOutput( output=OpenApiOperationOutput(
@@ -119,9 +119,9 @@ async def call_openapi_operation(
async def _send_request( async def _send_request(
client: httpx.AsyncClient, client: httpx2.AsyncClient,
request: HttpRequestParts, request: HttpRequestParts,
) -> httpx.Response: ) -> httpx2.Response:
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"method": request.method, "method": request.method,
"url": request.url, "url": request.url,
@@ -137,7 +137,7 @@ async def _send_request(
return await client.request(**kwargs) return await client.request(**kwargs)
def _response_body(response: httpx.Response) -> tuple[Any, list[str]]: def _response_body(response: httpx2.Response) -> tuple[Any, list[str]]:
"""Parse response body while keeping malformed JSON in validation flow.""" """Parse response body while keeping malformed JSON in validation flow."""
content_type = response.headers.get("content-type", "").lower() content_type = response.headers.get("content-type", "").lower()
if not response.content: if not response.content:
+11 -3
View File
@@ -10,7 +10,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Protocol from typing import Any, Protocol
import httpx import httpx2
from wf_api.auth import ( from wf_api.auth import (
AuthRecord as NeutralAuthRecord, AuthRecord as NeutralAuthRecord,
@@ -180,7 +180,7 @@ class HttpxOAuthTokenRefresher:
data["client_secret"] = auth.client_secret data["client_secret"] = auth.client_secret
if auth.scopes: if auth.scopes:
data["scope"] = " ".join(auth.scopes) data["scope"] = " ".join(auth.scopes)
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx2.AsyncClient(timeout=10.0) as client:
response = await client.post(str(auth.token_url), data=data) response = await client.post(str(auth.token_url), data=data)
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
@@ -198,8 +198,16 @@ class HttpxOAuthTokenRefresher:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class BoundMcpHttpAuth: class BoundMcpHttpAuth:
"""Auth material for MCP HTTP transports.
The MCP transport stack is httpx2-only: ``auth`` must be an httpx2.Auth
(or None). The authlib OAuth login flow in wf_cli stays on httpx by
design and never flows into this field; token refresh here is a plain
httpx2 POST.
"""
headers: dict[str, str] = field(default_factory=dict) headers: dict[str, str] = field(default_factory=dict)
auth: httpx.Auth | None = None auth: httpx2.Auth | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
+9 -5
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import httpx import httpx2
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
@@ -42,11 +42,12 @@ async def open_mcp_session(
duplicate keys) and passes command, args, env, and cwd to duplicate keys) and passes command, args, env, and cwd to
StdioServerParameters. StdioServerParameters.
For HTTP transports, creates an httpx.AsyncClient with auth headers and For HTTP transports, creates an httpx2.AsyncClient with auth headers and
enters streamable_http_client. enters streamable_http_client.
Yields an initialized ClientSession. Caller owns the session lifetime. Yields an initialized ClientSession. Caller owns the session lifetime.
""" """
transport = connection.transport transport = connection.transport
if transport is None: if transport is None:
raise ValueError(f"connection {connection.id!r} requires metadata.transport") raise ValueError(f"connection {connection.id!r} requires metadata.transport")
@@ -73,7 +74,7 @@ async def open_mcp_session(
if isinstance(transport, HttpSourceTransport): if isinstance(transport, HttpSourceTransport):
bound = await binder.bind_http_auth(stored_auth) bound = await binder.bind_http_auth(stored_auth)
http_client = httpx.AsyncClient( http_client = httpx2.AsyncClient(
headers=bound.headers or None, headers=bound.headers or None,
auth=bound.auth, auth=bound.auth,
) )
@@ -82,9 +83,12 @@ async def open_mcp_session(
streamable_http_client( streamable_http_client(
str(transport.url), str(transport.url),
http_client=http_client, http_client=http_client,
) as (read_stream, write_stream, _get_session_id), ) as (
read_stream,
write_stream,
),
ClientSession(read_stream, write_stream) as session,
): ):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize() await session.initialize()
yield session yield session
return return
+6 -16
View File
@@ -2,25 +2,15 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi.datastructures import _Unset
from fastapi_jsonrpc import Params from fastapi_jsonrpc import Params
class _RpcParams(Params):
def __init__(self, default: Any = ..., **extra: Any) -> None:
super().__init__(default, example=_Unset, **extra)
def RpcParams(default: Any = ...) -> Any: def RpcParams(default: Any = ...) -> Any:
"""Bind JSON-RPC method params without fastapi-jsonrpc's warning-prone wrapper. """Bind JSON-RPC method params with a zero-arg default.
``fastapi_jsonrpc.Params`` currently forwards ``example=Undefined`` into Upstream fixed the ``example``-sentinel warning in fastapi-jsonrpc 4.0, so
FastAPI's ``Body``. FastAPI treats that as the deprecated ``example`` this is now a plain pass-through. The wrapper stays (in this one file) so
argument being explicitly provided, so every method registration emits a the ``params: Model = RpcParams()`` call sites keep working: upstream
deprecation warning. Keep the upstream subclass so fastapi-jsonrpc still ``Params`` still requires ``default`` positionally.
recognises method params, but pass FastAPI's real "unset" sentinel.
This issue is fixed upstream at https://github.com/smagafurov/fastapi-jsonrpc/pull/101. Remove this wrapper once the next version of fastapi-jsonrpc is released and we upgrade to it.
""" """
# TODO return Params(default)
return _RpcParams(default)
+15 -15
View File
@@ -31,8 +31,8 @@ async def _fake_stdio_client(params: Any) -> AsyncIterator[tuple[Any, Any]]:
@asynccontextmanager @asynccontextmanager
async def _fake_streamable_http_client( async def _fake_streamable_http_client(
url: str, *, http_client: Any = None url: str, *, http_client: Any = None
) -> AsyncIterator[tuple[Any, Any, Any]]: ) -> AsyncIterator[tuple[Any, Any]]:
yield "read", "write", lambda: None yield "read", "write"
@asynccontextmanager @asynccontextmanager
@@ -169,10 +169,10 @@ async def test_http_auth_headers_passed_to_client(
payload={"token": "secret123"}, payload={"token": "secret123"},
) )
import httpx as _httpx import httpx2 as _httpx2
captured_clients: list[_httpx.AsyncClient] = [] captured_clients: list[_httpx2.AsyncClient] = []
_original_client = _httpx.AsyncClient _original_client = _httpx2.AsyncClient
class _CapturingClient(_original_client): # type: ignore[type-arg] class _CapturingClient(_original_client): # type: ignore[type-arg]
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
@@ -181,10 +181,10 @@ async def test_http_auth_headers_passed_to_client(
import wf_sources_mcp.client.transport as mod import wf_sources_mcp.client.transport as mod
class _PatchedHttpx: class _Patchedhttpx2:
AsyncClient = _CapturingClient AsyncClient = _CapturingClient
monkeypatch.setattr(mod, "httpx", _PatchedHttpx()) monkeypatch.setattr(mod, "httpx2", _Patchedhttpx2())
async with open_mcp_session(connection, auth) as session: async with open_mcp_session(connection, auth) as session:
assert isinstance(session, _FakeSession) assert isinstance(session, _FakeSession)
@@ -236,10 +236,10 @@ async def test_http_no_auth_creates_client_with_no_auth_headers(
) -> None: ) -> None:
connection = _http_connection() connection = _http_connection()
import httpx as _httpx import httpx2 as _httpx2
captured_clients: list[_httpx.AsyncClient] = [] captured_clients: list[_httpx2.AsyncClient] = []
_original_client = _httpx.AsyncClient _original_client = _httpx2.AsyncClient
class _CapturingClient(_original_client): # type: ignore[type-arg] class _CapturingClient(_original_client): # type: ignore[type-arg]
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
@@ -250,8 +250,8 @@ async def test_http_no_auth_creates_client_with_no_auth_headers(
monkeypatch.setattr( monkeypatch.setattr(
mod, mod,
"httpx", "httpx2",
type("_PatchedHttpx", (), {"AsyncClient": _CapturingClient})(), type("_Patchedhttpx2", (), {"AsyncClient": _CapturingClient})(),
) )
async with open_mcp_session(connection, None): async with open_mcp_session(connection, None):
@@ -282,7 +282,7 @@ async def test_open_mcp_session_uses_binder_for_http_headers(
import wf_sources_mcp.client.transport as mod import wf_sources_mcp.client.transport as mod
monkeypatch.setattr(mod.httpx, "AsyncClient", _CapturingClient) monkeypatch.setattr(mod.httpx2, "AsyncClient", _CapturingClient)
connection = _http_connection() connection = _http_connection()
auth = StoredAuthRecord( auth = StoredAuthRecord(
@@ -332,8 +332,8 @@ async def test_open_mcp_session_refreshes_oauth_record_for_http(
import wf_sources_mcp.auth as auth_mod import wf_sources_mcp.auth as auth_mod
import wf_sources_mcp.client.transport as transport_mod import wf_sources_mcp.client.transport as transport_mod
monkeypatch.setattr(auth_mod.httpx, "AsyncClient", _CapturingClient) monkeypatch.setattr(auth_mod.httpx2, "AsyncClient", _CapturingClient)
monkeypatch.setattr(transport_mod.httpx, "AsyncClient", _CapturingClient) monkeypatch.setattr(transport_mod.httpx2, "AsyncClient", _CapturingClient)
connection = _http_connection() connection = _http_connection()
auth = StoredAuthRecord( auth = StoredAuthRecord(