diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py index 27d9f470d..a6b88538e 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py @@ -18,6 +18,7 @@ from fastmcp.resources import ( ) from fastmcp.server.dependencies import get_http_headers from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.exceptions import is_request_error, is_timeout_error from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import HTTPRoute from fastmcp.utilities.openapi.director import RequestDirector @@ -73,6 +74,21 @@ def _raise_for_status(response: httpx2.Response) -> None: raise ValueError(error_message) +async def _send_request( + client: httpx2.AsyncClient, + request: httpx2.Request, +) -> httpx2.Response: + """Send a request while preserving transitional legacy-client errors.""" + try: + return await client.send(request) + except Exception as exc: + if is_timeout_error(exc): + raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc + if is_request_error(exc): + raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc + raise + + def _extract_mime_type_from_route(route: HTTPRoute) -> str: """Extract the primary MIME type from an HTTPRoute's response definitions. @@ -216,7 +232,7 @@ class OpenAPITool(Tool): f"run - sending request; headers: {_redact_headers(request.headers)}" ) - response = await self._client.send(request) + response = await _send_request(self._client, request) _raise_for_status(response) # Try to parse as JSON first @@ -244,11 +260,11 @@ class OpenAPITool(Tool): except json.JSONDecodeError: return ToolResult(content=response.text) - except httpx2.TimeoutException as e: - raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + except httpx2.TimeoutException as exc: + raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc - except httpx2.RequestError as e: - raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e + except httpx2.RequestError as exc: + raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc class OpenAPIResource(Resource): @@ -305,7 +321,7 @@ class OpenAPIResource(Resource): if mcp_headers: request.headers.update(mcp_headers) - response = await self._client.send(request) + response = await _send_request(self._client, request) _raise_for_status(response) content_type = response.headers.get("content-type", "").lower() @@ -334,11 +350,11 @@ class OpenAPIResource(Resource): ] ) - except httpx2.TimeoutException as e: - raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + except httpx2.TimeoutException as exc: + raise ValueError(f"HTTP request timed out ({type(exc).__name__})") from exc - except httpx2.RequestError as e: - raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e + except httpx2.RequestError as exc: + raise ValueError(f"Request error ({type(exc).__name__}): {exc!s}") from exc def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str: diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 8f8ccccbc..de3cba77b 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -88,6 +88,7 @@ from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.components import FastMCPComponent, _coerce_version +from fastmcp.utilities.exceptions import get_http_status_code, is_timeout_error from fastmcp.utilities.logging import get_logger from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import AnyFunction, FastMCPBaseModel, NotSet, NotSetT @@ -1540,12 +1541,11 @@ class FastMCP( logger.exception(f"Error calling tool {name!r}") # Handle actionable errors that should reach the LLM # even when masking is enabled - if isinstance(e, httpx2.HTTPStatusError): - if e.response.status_code == 429: - raise ToolError( - "Rate limited by upstream API, please retry later" - ) from e - if isinstance(e, httpx2.TimeoutException): + if get_http_status_code(e) == 429: + raise ToolError( + "Rate limited by upstream API, please retry later" + ) from e + if is_timeout_error(e): raise ToolError( "Upstream request timed out, please retry" ) from e @@ -1640,12 +1640,11 @@ class FastMCP( except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM - if isinstance(e, httpx2.HTTPStatusError): - if e.response.status_code == 429: - raise ResourceError( - "Rate limited by upstream API, please retry later" - ) from e - if isinstance(e, httpx2.TimeoutException): + if get_http_status_code(e) == 429: + raise ResourceError( + "Rate limited by upstream API, please retry later" + ) from e + if is_timeout_error(e): raise ResourceError( "Upstream request timed out, please retry" ) from e @@ -1700,12 +1699,11 @@ class FastMCP( except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM - if isinstance(e, httpx2.HTTPStatusError): - if e.response.status_code == 429: - raise ResourceError( - "Rate limited by upstream API, please retry later" - ) from e - if isinstance(e, httpx2.TimeoutException): + if get_http_status_code(e) == 429: + raise ResourceError( + "Rate limited by upstream API, please retry later" + ) from e + if is_timeout_error(e): raise ResourceError( "Upstream request timed out, please retry" ) from e diff --git a/fastmcp_slim/fastmcp/utilities/exceptions.py b/fastmcp_slim/fastmcp/utilities/exceptions.py index 20241bc2f..97cea8f29 100644 --- a/fastmcp_slim/fastmcp/utilities/exceptions.py +++ b/fastmcp_slim/fastmcp/utilities/exceptions.py @@ -8,6 +8,43 @@ from mcp import MCPError import fastmcp +def _is_legacy_httpx_exception(exc: BaseException, exception_type: str) -> bool: + """Check a legacy-httpx exception without importing the legacy package.""" + return any( + cls.__module__.partition(".")[0] == "httpx" and cls.__name__ == exception_type + for cls in type(exc).__mro__ + ) + + +def is_http_status_error(exc: BaseException) -> bool: + """Return whether an exception is an httpx2 or legacy-httpx status error.""" + return isinstance(exc, httpx2.HTTPStatusError) or _is_legacy_httpx_exception( + exc, "HTTPStatusError" + ) + + +def get_http_status_code(exc: BaseException) -> int | None: + """Return the response status code from a recognized HTTP status error.""" + if not is_http_status_error(exc): + return None + status_code = getattr(getattr(exc, "response", None), "status_code", None) + return status_code if isinstance(status_code, int) else None + + +def is_timeout_error(exc: BaseException) -> bool: + """Return whether an exception is an httpx2 or legacy-httpx timeout.""" + return isinstance(exc, httpx2.TimeoutException) or _is_legacy_httpx_exception( + exc, "TimeoutException" + ) + + +def is_request_error(exc: BaseException) -> bool: + """Return whether an exception is an httpx2 or legacy-httpx request error.""" + return isinstance(exc, httpx2.RequestError) or _is_legacy_httpx_exception( + exc, "RequestError" + ) + + def iter_exc(group: BaseExceptionGroup): for exc in group.exceptions: if isinstance(exc, BaseExceptionGroup): diff --git a/tests/server/providers/openapi/test_legacy_client_compat.py b/tests/server/providers/openapi/test_legacy_client_compat.py index 4519604a8..3b0361ad8 100644 --- a/tests/server/providers/openapi/test_legacy_client_compat.py +++ b/tests/server/providers/openapi/test_legacy_client_compat.py @@ -77,3 +77,34 @@ async def test_legacy_client_preserves_http_error_details() -> None: await mcp_client.call_tool("list_items", {}) assert "items not found" in str(exc_info.value) + + +@pytest.mark.parametrize( + ("error_kind", "message"), + [ + ("timeout", "HTTP request timed out (ReadTimeout)"), + ("connect", "Request error (ConnectError)"), + ], +) +async def test_legacy_client_preserves_transport_error_details( + error_kind: str, + message: str, +) -> None: + def handler(request: "httpx.Request") -> "httpx.Response": + if error_kind == "timeout": + raise httpx.ReadTimeout("transport failed", request=request) + raise httpx.ConnectError("transport failed", request=request) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + transport=transport, + base_url="https://api.example.com", + ) as client: + with pytest.warns(FastMCPDeprecationWarning): + server = FastMCP.from_openapi(SPEC, client=client) + + async with Client(server) as mcp_client: + with pytest.raises(ToolError) as exc_info: + await mcp_client.call_tool("list_items", {}) + + assert message in str(exc_info.value) diff --git a/tests/server/test_legacy_httpx_errors.py b/tests/server/test_legacy_httpx_errors.py new file mode 100644 index 000000000..38f9434a0 --- /dev/null +++ b/tests/server/test_legacy_httpx_errors.py @@ -0,0 +1,33 @@ +"""Compatibility tests for legacy-httpx exceptions raised by user code.""" + +import pytest + +from fastmcp import FastMCP +from fastmcp.exceptions import ResourceError, ToolError + +httpx = pytest.importorskip("httpx", reason="legacy httpx not installed") + + +async def test_legacy_httpx_rate_limit_remains_actionable() -> None: + server = FastMCP("Legacy httpx errors", mask_error_details=True) + + @server.tool + def rate_limited() -> None: + request = httpx.Request("GET", "https://example.com") + response = httpx.Response(429, request=request) + raise httpx.HTTPStatusError("rate limited", request=request, response=response) + + with pytest.raises(ToolError, match="Rate limited by upstream API"): + await server.call_tool("rate_limited", {}) + + +async def test_legacy_httpx_resource_timeout_remains_actionable() -> None: + server = FastMCP("Legacy httpx errors", mask_error_details=True) + + @server.resource("resource://timed-out") + def timed_out() -> str: + request = httpx.Request("GET", "https://example.com") + raise httpx.ReadTimeout("timed out", request=request) + + with pytest.raises(ResourceError, match="Upstream request timed out"): + await server.read_resource("resource://timed-out")