diff --git a/libs/arcade-core/arcade_core/errors.py b/libs/arcade-core/arcade_core/errors.py index e9df316c..3c7214b9 100644 --- a/libs/arcade-core/arcade_core/errors.py +++ b/libs/arcade-core/arcade_core/errors.py @@ -26,6 +26,9 @@ class ErrorKind(str, Enum): UPSTREAM_RUNTIME_RATE_LIMIT = "UPSTREAM_RUNTIME_RATE_LIMIT" UPSTREAM_RUNTIME_SERVER_ERROR = "UPSTREAM_RUNTIME_SERVER_ERROR" UPSTREAM_RUNTIME_UNMAPPED = "UPSTREAM_RUNTIME_UNMAPPED" + NETWORK_TRANSPORT_RUNTIME_TIMEOUT = "NETWORK_TRANSPORT_RUNTIME_TIMEOUT" + NETWORK_TRANSPORT_RUNTIME_UNREACHABLE = "NETWORK_TRANSPORT_RUNTIME_UNREACHABLE" + NETWORK_TRANSPORT_RUNTIME_UNMAPPED = "NETWORK_TRANSPORT_RUNTIME_UNMAPPED" UNKNOWN = "UNKNOWN" @@ -93,6 +96,12 @@ class ToolkitError(Exception, ABC): """Check if this error originated from an upstream service.""" return hasattr(self, "kind") and self.kind.name.startswith("UPSTREAM_") + @property + def is_network_transport_error(self) -> bool: + """Check if this error originated from a network-transport-level failure + (no complete response from the upstream was received).""" + return hasattr(self, "kind") and self.kind.name.startswith("NETWORK_TRANSPORT_") + def __str__(self) -> str: return self.message @@ -362,6 +371,43 @@ class UpstreamError(ToolExecutionError): self.kind = ErrorKind.UPSTREAM_RUNTIME_UNMAPPED +# 4. ------ network-transport errors in tool body ------ +class NetworkTransportError(ToolExecutionError): + """ + Error from a network-transport-level failure during tool execution. + + Raised when a tool's outbound request could not complete an exchange with the + upstream service: the request either never reached the upstream, or a complete + response never came back. Covers timeouts, connection failures, DNS errors, + pool exhaustion, response decoding failures, and redirect-loop exhaustion. + + Distinct from ``UpstreamError``: here the upstream never produced a complete + HTTP response, so no status code is available. Distinct from + ``FatalToolError``: the failure is a runtime transport issue (typically + transient) rather than a tool-authoring bug. + """ + + _ALLOWED_KIND_PREFIX = "NETWORK_TRANSPORT_" + + def __init__( + self, + message: str, + developer_message: str | None = None, + *, + kind: ErrorKind = ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED, + can_retry: bool = True, + extra: dict[str, Any] | None = None, + ): + super().__init__(message, developer_message=developer_message, extra=extra) + if not kind.name.startswith(self._ALLOWED_KIND_PREFIX): + raise ValueError( + f"NetworkTransportError kind must start with " + f"{self._ALLOWED_KIND_PREFIX!r}, got {kind.name!r}" + ) + self.kind = kind + self.can_retry = can_retry + + class UpstreamRateLimitError(UpstreamError): """ Rate limit error from an upstream service. diff --git a/libs/arcade-core/arcade_core/schema.py b/libs/arcade-core/arcade_core/schema.py index c8189cc2..1859e632 100644 --- a/libs/arcade-core/arcade_core/schema.py +++ b/libs/arcade-core/arcade_core/schema.py @@ -614,6 +614,12 @@ class ToolCallError(BaseModel): """Check if this error originated from an upstream service.""" return self.kind.name.startswith("UPSTREAM_") + @property + def is_network_transport_error(self) -> bool: + """Check if this error originated from a network-transport-level failure + (no complete response from the upstream was received).""" + return self.kind.name.startswith("NETWORK_TRANSPORT_") + class ToolCallRequiresAuthorization(BaseModel): """The authorization requirements for the tool invocation.""" diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 33a3baa1..b5ee335f 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "4.6.2" +version = "4.7.0" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = { text = "MIT" } diff --git a/libs/arcade-mcp-server/arcade_mcp_server/exceptions.py b/libs/arcade-mcp-server/arcade_mcp_server/exceptions.py index d2deee89..b3935347 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/exceptions.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/exceptions.py @@ -8,6 +8,7 @@ from arcade_core.errors import ( ContextRequiredToolError, ErrorKind, FatalToolError, + NetworkTransportError, RetryableToolError, ToolExecutionError, ToolRuntimeError, @@ -19,6 +20,7 @@ __all__ = [ # Re-exports "ErrorKind", "FatalToolError", + "NetworkTransportError", "RetryableToolError", "ToolExecutionError", "ToolRuntimeError", diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index bebdffa2..3e83b237 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "arcade-mcp-server" -version = "1.19.3" +version = "1.20.0" description = "Model Context Protocol (MCP) server framework for Arcade.dev" readme = "README.md" authors = [{ name = "Arcade.dev" }] @@ -21,9 +21,9 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=4.6.1,<5.0.0", + "arcade-core>=4.7.0,<5.0.0", "arcade-serve>=3.2.0,<4.0.0", - "arcade-tdk>=3.6.0,<4.0.0", + "arcade-tdk>=3.7.0,<4.0.0", "arcadepy>=1.5.0", "pydantic>=2.0.0", "fastapi>=0.100.0", diff --git a/libs/arcade-tdk/arcade_tdk/errors.py b/libs/arcade-tdk/arcade_tdk/errors.py index 5b905649..39d44ceb 100644 --- a/libs/arcade-tdk/arcade_tdk/errors.py +++ b/libs/arcade-tdk/arcade_tdk/errors.py @@ -2,6 +2,7 @@ from arcade_core.errors import ( ContextRequiredToolError, ErrorKind, FatalToolError, + NetworkTransportError, RetryableToolError, ToolExecutionError, ToolRuntimeError, @@ -13,6 +14,7 @@ __all__ = [ "ContextRequiredToolError", "ErrorKind", "FatalToolError", + "NetworkTransportError", "RetryableToolError", "SDKError", "ToolExecutionError", diff --git a/libs/arcade-tdk/pyproject.toml b/libs/arcade-tdk/pyproject.toml index e14de148..53c62f64 100644 --- a/libs/arcade-tdk/pyproject.toml +++ b/libs/arcade-tdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-tdk" -version = "3.6.1" +version = "3.7.0" description = "Arcade TDK - Toolkit Development Kit for building Arcade tools" readme = "README.md" license = { text = "MIT" } @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] requires-python = ">=3.10" -dependencies = ["arcade-core>=4.4.0,<5.0.0", "pydantic>=2.7.0"] +dependencies = ["arcade-core>=4.7.0,<5.0.0", "pydantic>=2.7.0"] [project.optional-dependencies] dev = [ diff --git a/libs/tests/core/test_errors.py b/libs/tests/core/test_errors.py index de80aeae..13b7ef88 100644 --- a/libs/tests/core/test_errors.py +++ b/libs/tests/core/test_errors.py @@ -6,7 +6,15 @@ that carries no diagnostic payload in logs/agent output. """ import pytest -from arcade_core.errors import FatalToolError, RetryableToolError, ToolkitLoadError +from arcade_core.errors import ( + ErrorKind, + FatalToolError, + NetworkTransportError, + RetryableToolError, + ToolkitLoadError, + UpstreamError, +) +from arcade_core.schema import ToolCallError @pytest.mark.parametrize("empty_message", ["", " ", "\t", "\n \n"]) @@ -45,3 +53,87 @@ def test_with_context_toolkit_load_error_empty_message(): err = ToolkitLoadError("").with_context("broken_toolkit") assert "broken_toolkit" in err.message assert "(no details provided)" in err.message + + +# ---- NetworkTransportError ------------------------------------------------- + + +def test_network_transport_error_is_sibling_to_upstream_error(): + """NetworkTransportError and UpstreamError serve different semantic roles. + + The classification helpers must not mix them up — any consumer keying on + ``is_upstream_error`` (telemetry dashboards, retry playbooks) relies on + that distinction being clean. + """ + nte = NetworkTransportError("boom") + ue = UpstreamError("boom", status_code=500) + + assert nte.is_network_transport_error is True + assert nte.is_upstream_error is False + + assert ue.is_upstream_error is True + assert ue.is_network_transport_error is False + + +def test_network_transport_error_defaults(): + err = NetworkTransportError("boom") + assert err.kind is ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED + assert err.can_retry is True + # No complete response was received, so there is no status code. + assert err.status_code is None + + +def test_network_transport_error_rejects_non_network_kind(): + """The class invariant — kind must be in the NETWORK_TRANSPORT_ namespace — + protects telemetry and classification helpers from accidental pollution.""" + with pytest.raises(ValueError, match="NETWORK_TRANSPORT_"): + NetworkTransportError("x", kind=ErrorKind.UPSTREAM_RUNTIME_SERVER_ERROR) + + +def test_network_transport_error_to_payload_omits_status_code(): + err = NetworkTransportError( + "timed out", + kind=ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT, + can_retry=True, + extra={"error_type": "PoolTimeout"}, + ) + payload = err.to_payload() + assert payload["status_code"] is None + assert payload["kind"] is ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT + assert payload["can_retry"] is True + assert payload["error_type"] == "PoolTimeout" + + +# ---- ToolCallError classification properties (wire-format schema) ---------- + + +@pytest.mark.parametrize( + "kind, expected_tool, expected_upstream, expected_network, expected_toolkit", + [ + (ErrorKind.TOOL_RUNTIME_FATAL, True, False, False, False), + (ErrorKind.TOOL_RUNTIME_RETRY, True, False, False, False), + (ErrorKind.TOOL_RUNTIME_CONTEXT_REQUIRED, True, False, False, False), + (ErrorKind.TOOL_RUNTIME_BAD_INPUT_VALUE, True, False, False, False), + (ErrorKind.TOOL_RUNTIME_BAD_OUTPUT_VALUE, True, False, False, False), + (ErrorKind.UPSTREAM_RUNTIME_SERVER_ERROR, False, True, False, False), + (ErrorKind.UPSTREAM_RUNTIME_AUTH_ERROR, False, True, False, False), + (ErrorKind.UPSTREAM_RUNTIME_NOT_FOUND, False, True, False, False), + (ErrorKind.UPSTREAM_RUNTIME_RATE_LIMIT, False, True, False, False), + (ErrorKind.UPSTREAM_RUNTIME_BAD_REQUEST, False, True, False, False), + (ErrorKind.UPSTREAM_RUNTIME_UNMAPPED, False, True, False, False), + (ErrorKind.NETWORK_TRANSPORT_RUNTIME_TIMEOUT, False, False, True, False), + (ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNREACHABLE, False, False, True, False), + (ErrorKind.NETWORK_TRANSPORT_RUNTIME_UNMAPPED, False, False, True, False), + (ErrorKind.TOOLKIT_LOAD_FAILED, False, False, False, True), + ], +) +def test_tool_call_error_classification_properties( + kind, expected_tool, expected_upstream, expected_network, expected_toolkit +): + """ToolCallError (Pydantic wire-format model) classification helpers must + be consistent with the ToolkitError class hierarchy helpers.""" + err = ToolCallError(message="test", kind=kind, can_retry=False) + assert err.is_tool_error is expected_tool + assert err.is_upstream_error is expected_upstream + assert err.is_network_transport_error is expected_network + assert err.is_toolkit_error is expected_toolkit