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/3] 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/3] 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 From eec14431026d7d66a173e6395629a65b8625ae48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 10:07:13 +0000 Subject: [PATCH 3/3] fix: raise ResourceNotExistError for data-plane not-found, add boundary tests Agent-Logs-Url: https://github.com/Serverless-Devs/agentrun-sdk-python/sessions/5b18eef6-655f-4a7b-be96-f7e39f852e04 Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- agentrun/sandbox/client.py | 48 ++++++++----- tests/unittests/sandbox/test_client.py | 95 ++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/agentrun/sandbox/client.py b/agentrun/sandbox/client.py index 6b2c2a4..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,15 +732,19 @@ 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", "") + # 数据面报告 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(): - return Sandbox.model_validate( - {"sandboxId": sandbox_id}, by_alias=True - ) + raise ResourceNotExistError("Sandbox", sandbox_id) raise ClientError( status_code=0, message=( @@ -766,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: 服务器错误 """ @@ -777,15 +789,19 @@ 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", "") + # 数据面报告 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(): - return Sandbox.model_validate( - {"sandboxId": sandbox_id}, by_alias=True - ) + raise ResourceNotExistError("Sandbox", sandbox_id) raise ClientError( status_code=0, message=( diff --git a/tests/unittests/sandbox/test_client.py b/tests/unittests/sandbox/test_client.py index 6a6537f..53b17ed 100644 --- a/tests/unittests/sandbox/test_client.py +++ b/tests/unittests/sandbox/test_client.py @@ -812,16 +812,14 @@ def test_delete_sandbox_not_exist( @patch("agentrun.sandbox.client.SandboxControlAPI") @patch("agentrun.sandbox.client.SandboxDataAPI") - def test_delete_sandbox_not_found_in_response_is_idempotent( + def test_delete_sandbox_not_found_in_response_raises_resource_not_exist( self, mock_data_api_class, mock_control_api_class ): - """数据面返回 not found 时,delete_sandbox 应幂等成功 + """数据面业务层返回 not-found 时,与 HTTP 404 路径统一抛 ResourceNotExistError。 - 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. + 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 = { @@ -831,16 +829,67 @@ def test_delete_sandbox_not_found_in_response_is_idempotent( mock_data_api_class.return_value = mock_data_api client = SandboxClient() - result = client.delete_sandbox("sandbox-123") - assert result.sandbox_id == "sandbox-123" + 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_is_idempotent( + 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 时,delete_sandbox_async 应幂等成功""" + """数据面业务层返回 not-found 时(async),与 HTTP 404 路径统一抛 ResourceNotExistError。""" mock_data_api = MagicMock() mock_data_api.delete_sandbox_async = AsyncMock( return_value={ @@ -851,8 +900,28 @@ async def test_delete_sandbox_async_not_found_in_response_is_idempotent( 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" + 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")