fix: repair httpx2/params/transport upgrade fallout
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user