From ac00d4573ae3e3802a32fcfe5ad631f98b9c8b44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:53:13 +0000 Subject: [PATCH 1/2] Initial plan From 7a82fe8ffa05984c7352a620ceedfa0dd7c5238e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:07:19 +0000 Subject: [PATCH 2/2] fix: make delete_sandbox idempotent when data plane returns sandbox not found Agent-Logs-Url: https://github.com/Serverless-Devs/agentrun-sdk-python/sessions/82dd0492-f264-497a-9397-ffb5e79a1d90 Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- agentrun/sandbox/client.py | 22 +++++++++++-- tests/unittests/sandbox/test_client.py | 44 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/agentrun/sandbox/client.py b/agentrun/sandbox/client.py index b315b45..6b2c2a4 100644 --- a/agentrun/sandbox/client.py +++ b/agentrun/sandbox/client.py @@ -728,11 +728,20 @@ async def delete_sandbox_async( # 判断返回结果是否成功 if result.get("code") != "SUCCESS": + # 数据面报告 sandbox 不存在时,视为幂等删除成功 + # When the data plane reports sandbox not found, treat as + # idempotent success (control plane may still list TERMINATED + # instances after the data plane has already removed them) + message = result.get("message", "") + if "sandbox not found" in message.lower(): + return Sandbox.model_validate( + {"sandboxId": sandbox_id}, by_alias=True + ) raise ClientError( status_code=0, message=( "Failed to stop sandbox:" - f" {result.get('message', 'Unknown error')}" + f" {message or 'Unknown error'}" ), ) @@ -768,11 +777,20 @@ def delete_sandbox( # 判断返回结果是否成功 if result.get("code") != "SUCCESS": + # 数据面报告 sandbox 不存在时,视为幂等删除成功 + # When the data plane reports sandbox not found, treat as + # idempotent success (control plane may still list TERMINATED + # instances after the data plane has already removed them) + message = result.get("message", "") + if "sandbox not found" in message.lower(): + return Sandbox.model_validate( + {"sandboxId": sandbox_id}, by_alias=True + ) 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..6a6537f 100644 --- a/tests/unittests/sandbox/test_client.py +++ b/tests/unittests/sandbox/test_client.py @@ -810,6 +810,50 @@ 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_is_idempotent( + self, mock_data_api_class, mock_control_api_class + ): + """数据面返回 not found 时,delete_sandbox 应幂等成功 + + When the data plane returns a non-SUCCESS response whose message + contains "not found", the SDK should treat the delete as a success + rather than raising an error. This handles the case where the + control-plane list API still shows a TERMINATED sandbox, but the + data plane has already removed it. + """ + 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() + result = client.delete_sandbox("sandbox-123") + assert result.sandbox_id == "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_is_idempotent( + self, mock_data_api_class, mock_control_api_class + ): + """数据面返回 not found 时,delete_sandbox_async 应幂等成功""" + 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() + result = await client.delete_sandbox_async("sandbox-123") + assert result.sandbox_id == "sandbox-123" + @patch("agentrun.sandbox.client.SandboxControlAPI") @patch("agentrun.sandbox.client.SandboxDataAPI") @pytest.mark.asyncio