diff --git a/agentrun/sandbox/client.py b/agentrun/sandbox/client.py index b315b45..e2bc63d 100644 --- a/agentrun/sandbox/client.py +++ b/agentrun/sandbox/client.py @@ -717,7 +717,11 @@ async def delete_sandbox_async( Sandbox: 停止后的 Sandbox 对象 Raises: - ResourceNotExistError: Sandbox 不存在 + ResourceNotExistError: Sandbox 不存在(包括 HTTP 404 与数据面业务层 + not-found 两种情形)。调用方可 catch 此异常实现幂等删除。 + Sandbox does not exist (covers both HTTP 404 and data-plane + business-level not-found). Callers can catch this exception + for idempotent delete logic. ClientError: 客户端错误 ServerError: 服务器错误 """ @@ -728,11 +732,24 @@ async def delete_sandbox_async( # 判断返回结果是否成功 if result.get("code") != "SUCCESS": + message = result.get("message", "") + # 数据面报告 sandbox 不存在时,与 HTTP 404 路径保持一致, + # 统一抛出 ResourceNotExistError,方便调用方幂等处理。 + # When the data plane reports sandbox not found, raise + # ResourceNotExistError for consistency with the HTTP 404 path. + # Callers can catch ResourceNotExistError to implement idempotent + # deletion (e.g. when TERMINATED instances still appear in list + # results but have already been removed from the data plane). + # Note: long-term the server should return a stable error_code + # (e.g. SandboxNotFound) so the SDK can match on that instead + # of a message string. + if "sandbox not found" in message.lower(): + raise ResourceNotExistError("Sandbox", sandbox_id) raise ClientError( status_code=0, message=( "Failed to stop sandbox:" - f" {result.get('message', 'Unknown error')}" + f" {message or 'Unknown error'}" ), ) @@ -757,7 +774,11 @@ def delete_sandbox( Sandbox: 停止后的 Sandbox 对象 Raises: - ResourceNotExistError: Sandbox 不存在 + ResourceNotExistError: Sandbox 不存在(包括 HTTP 404 与数据面业务层 + not-found 两种情形)。调用方可 catch 此异常实现幂等删除。 + Sandbox does not exist (covers both HTTP 404 and data-plane + business-level not-found). Callers can catch this exception + for idempotent delete logic. ClientError: 客户端错误 ServerError: 服务器错误 """ @@ -768,11 +789,24 @@ def delete_sandbox( # 判断返回结果是否成功 if result.get("code") != "SUCCESS": + message = result.get("message", "") + # 数据面报告 sandbox 不存在时,与 HTTP 404 路径保持一致, + # 统一抛出 ResourceNotExistError,方便调用方幂等处理。 + # When the data plane reports sandbox not found, raise + # ResourceNotExistError for consistency with the HTTP 404 path. + # Callers can catch ResourceNotExistError to implement idempotent + # deletion (e.g. when TERMINATED instances still appear in list + # results but have already been removed from the data plane). + # Note: long-term the server should return a stable error_code + # (e.g. SandboxNotFound) so the SDK can match on that instead + # of a message string. + if "sandbox not found" in message.lower(): + raise ResourceNotExistError("Sandbox", sandbox_id) raise ClientError( status_code=0, message=( "Failed to stop sandbox:" - f" {result.get('message', 'Unknown error')}" + f" {message or 'Unknown error'}" ), ) diff --git a/tests/unittests/sandbox/test_client.py b/tests/unittests/sandbox/test_client.py index 41fe3c0..53b17ed 100644 --- a/tests/unittests/sandbox/test_client.py +++ b/tests/unittests/sandbox/test_client.py @@ -810,6 +810,119 @@ def test_delete_sandbox_not_exist( with pytest.raises(ResourceNotExistError): client.delete_sandbox("nonexistent") + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_not_found_in_response_raises_resource_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + """数据面业务层返回 not-found 时,与 HTTP 404 路径统一抛 ResourceNotExistError。 + + Callers can catch ResourceNotExistError for idempotent deletion when the + control plane still lists a TERMINATED sandbox but the data plane has + already removed it (e.g. ``except ResourceNotExistError: pass``). + """ + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "FAILED", + "message": "sandbox not found", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.delete_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_not_found_case_insensitive( + self, mock_data_api_class, mock_control_api_class + ): + """大小写变体(如 'Sandbox NOT FOUND')也应触发 ResourceNotExistError。""" + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "FAILED", + "message": "Sandbox NOT FOUND", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + client.delete_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_other_failure_message_raises_client_error( + self, mock_data_api_class, mock_control_api_class + ): + """无关 not-found 的失败消息(如 'sandbox is busy')应仍抛 ClientError。""" + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "FAILED", + "message": "sandbox is busy", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + client.delete_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + def test_delete_sandbox_empty_message_raises_client_error( + self, mock_data_api_class, mock_control_api_class + ): + """message 为空时不应误触 not-found 逻辑,应抛 ClientError。""" + mock_data_api = MagicMock() + mock_data_api.delete_sandbox.return_value = { + "code": "FAILED", + "message": "", + } + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + client.delete_sandbox("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_sandbox_async_not_found_in_response_raises_resource_not_exist( + self, mock_data_api_class, mock_control_api_class + ): + """数据面业务层返回 not-found 时(async),与 HTTP 404 路径统一抛 ResourceNotExistError。""" + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + return_value={ + "code": "FAILED", + "message": "sandbox not found", + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ResourceNotExistError): + await client.delete_sandbox_async("sandbox-123") + + @patch("agentrun.sandbox.client.SandboxControlAPI") + @patch("agentrun.sandbox.client.SandboxDataAPI") + @pytest.mark.asyncio + async def test_delete_sandbox_async_other_failure_raises_client_error( + self, mock_data_api_class, mock_control_api_class + ): + """无关 not-found 的失败消息(async)应仍抛 ClientError。""" + mock_data_api = MagicMock() + mock_data_api.delete_sandbox_async = AsyncMock( + return_value={ + "code": "FAILED", + "message": "sandbox is busy", + } + ) + mock_data_api_class.return_value = mock_data_api + + client = SandboxClient() + with pytest.raises(ClientError, match="Failed to stop sandbox"): + await client.delete_sandbox_async("sandbox-123") + @patch("agentrun.sandbox.client.SandboxControlAPI") @patch("agentrun.sandbox.client.SandboxDataAPI") @pytest.mark.asyncio