Skip to content

Commit eec1443

Browse files
CopilotOhYee
andauthored
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>
1 parent 7a82fe8 commit eec1443

2 files changed

Lines changed: 114 additions & 29 deletions

File tree

agentrun/sandbox/client.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,11 @@ async def delete_sandbox_async(
717717
Sandbox: 停止后的 Sandbox 对象
718718
719719
Raises:
720-
ResourceNotExistError: Sandbox 不存在
720+
ResourceNotExistError: Sandbox 不存在(包括 HTTP 404 与数据面业务层
721+
not-found 两种情形)。调用方可 catch 此异常实现幂等删除。
722+
Sandbox does not exist (covers both HTTP 404 and data-plane
723+
business-level not-found). Callers can catch this exception
724+
for idempotent delete logic.
721725
ClientError: 客户端错误
722726
ServerError: 服务器错误
723727
"""
@@ -728,15 +732,19 @@ async def delete_sandbox_async(
728732

729733
# 判断返回结果是否成功
730734
if result.get("code") != "SUCCESS":
731-
# 数据面报告 sandbox 不存在时,视为幂等删除成功
732-
# When the data plane reports sandbox not found, treat as
733-
# idempotent success (control plane may still list TERMINATED
734-
# instances after the data plane has already removed them)
735735
message = result.get("message", "")
736+
# 数据面报告 sandbox 不存在时,与 HTTP 404 路径保持一致,
737+
# 统一抛出 ResourceNotExistError,方便调用方幂等处理。
738+
# When the data plane reports sandbox not found, raise
739+
# ResourceNotExistError for consistency with the HTTP 404 path.
740+
# Callers can catch ResourceNotExistError to implement idempotent
741+
# deletion (e.g. when TERMINATED instances still appear in list
742+
# results but have already been removed from the data plane).
743+
# Note: long-term the server should return a stable error_code
744+
# (e.g. SandboxNotFound) so the SDK can match on that instead
745+
# of a message string.
736746
if "sandbox not found" in message.lower():
737-
return Sandbox.model_validate(
738-
{"sandboxId": sandbox_id}, by_alias=True
739-
)
747+
raise ResourceNotExistError("Sandbox", sandbox_id)
740748
raise ClientError(
741749
status_code=0,
742750
message=(
@@ -766,7 +774,11 @@ def delete_sandbox(
766774
Sandbox: 停止后的 Sandbox 对象
767775
768776
Raises:
769-
ResourceNotExistError: Sandbox 不存在
777+
ResourceNotExistError: Sandbox 不存在(包括 HTTP 404 与数据面业务层
778+
not-found 两种情形)。调用方可 catch 此异常实现幂等删除。
779+
Sandbox does not exist (covers both HTTP 404 and data-plane
780+
business-level not-found). Callers can catch this exception
781+
for idempotent delete logic.
770782
ClientError: 客户端错误
771783
ServerError: 服务器错误
772784
"""
@@ -777,15 +789,19 @@ def delete_sandbox(
777789

778790
# 判断返回结果是否成功
779791
if result.get("code") != "SUCCESS":
780-
# 数据面报告 sandbox 不存在时,视为幂等删除成功
781-
# When the data plane reports sandbox not found, treat as
782-
# idempotent success (control plane may still list TERMINATED
783-
# instances after the data plane has already removed them)
784792
message = result.get("message", "")
793+
# 数据面报告 sandbox 不存在时,与 HTTP 404 路径保持一致,
794+
# 统一抛出 ResourceNotExistError,方便调用方幂等处理。
795+
# When the data plane reports sandbox not found, raise
796+
# ResourceNotExistError for consistency with the HTTP 404 path.
797+
# Callers can catch ResourceNotExistError to implement idempotent
798+
# deletion (e.g. when TERMINATED instances still appear in list
799+
# results but have already been removed from the data plane).
800+
# Note: long-term the server should return a stable error_code
801+
# (e.g. SandboxNotFound) so the SDK can match on that instead
802+
# of a message string.
785803
if "sandbox not found" in message.lower():
786-
return Sandbox.model_validate(
787-
{"sandboxId": sandbox_id}, by_alias=True
788-
)
804+
raise ResourceNotExistError("Sandbox", sandbox_id)
789805
raise ClientError(
790806
status_code=0,
791807
message=(

tests/unittests/sandbox/test_client.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -812,16 +812,14 @@ def test_delete_sandbox_not_exist(
812812

813813
@patch("agentrun.sandbox.client.SandboxControlAPI")
814814
@patch("agentrun.sandbox.client.SandboxDataAPI")
815-
def test_delete_sandbox_not_found_in_response_is_idempotent(
815+
def test_delete_sandbox_not_found_in_response_raises_resource_not_exist(
816816
self, mock_data_api_class, mock_control_api_class
817817
):
818-
"""数据面返回 not found 时,delete_sandbox 应幂等成功
818+
"""数据面业务层返回 not-found 时,与 HTTP 404 路径统一抛 ResourceNotExistError。
819819
820-
When the data plane returns a non-SUCCESS response whose message
821-
contains "not found", the SDK should treat the delete as a success
822-
rather than raising an error. This handles the case where the
823-
control-plane list API still shows a TERMINATED sandbox, but the
824-
data plane has already removed it.
820+
Callers can catch ResourceNotExistError for idempotent deletion when the
821+
control plane still lists a TERMINATED sandbox but the data plane has
822+
already removed it (e.g. ``except ResourceNotExistError: pass``).
825823
"""
826824
mock_data_api = MagicMock()
827825
mock_data_api.delete_sandbox.return_value = {
@@ -831,16 +829,67 @@ def test_delete_sandbox_not_found_in_response_is_idempotent(
831829
mock_data_api_class.return_value = mock_data_api
832830

833831
client = SandboxClient()
834-
result = client.delete_sandbox("sandbox-123")
835-
assert result.sandbox_id == "sandbox-123"
832+
with pytest.raises(ResourceNotExistError):
833+
client.delete_sandbox("sandbox-123")
834+
835+
@patch("agentrun.sandbox.client.SandboxControlAPI")
836+
@patch("agentrun.sandbox.client.SandboxDataAPI")
837+
def test_delete_sandbox_not_found_case_insensitive(
838+
self, mock_data_api_class, mock_control_api_class
839+
):
840+
"""大小写变体(如 'Sandbox NOT FOUND')也应触发 ResourceNotExistError。"""
841+
mock_data_api = MagicMock()
842+
mock_data_api.delete_sandbox.return_value = {
843+
"code": "FAILED",
844+
"message": "Sandbox NOT FOUND",
845+
}
846+
mock_data_api_class.return_value = mock_data_api
847+
848+
client = SandboxClient()
849+
with pytest.raises(ResourceNotExistError):
850+
client.delete_sandbox("sandbox-123")
851+
852+
@patch("agentrun.sandbox.client.SandboxControlAPI")
853+
@patch("agentrun.sandbox.client.SandboxDataAPI")
854+
def test_delete_sandbox_other_failure_message_raises_client_error(
855+
self, mock_data_api_class, mock_control_api_class
856+
):
857+
"""无关 not-found 的失败消息(如 'sandbox is busy')应仍抛 ClientError。"""
858+
mock_data_api = MagicMock()
859+
mock_data_api.delete_sandbox.return_value = {
860+
"code": "FAILED",
861+
"message": "sandbox is busy",
862+
}
863+
mock_data_api_class.return_value = mock_data_api
864+
865+
client = SandboxClient()
866+
with pytest.raises(ClientError, match="Failed to stop sandbox"):
867+
client.delete_sandbox("sandbox-123")
868+
869+
@patch("agentrun.sandbox.client.SandboxControlAPI")
870+
@patch("agentrun.sandbox.client.SandboxDataAPI")
871+
def test_delete_sandbox_empty_message_raises_client_error(
872+
self, mock_data_api_class, mock_control_api_class
873+
):
874+
"""message 为空时不应误触 not-found 逻辑,应抛 ClientError。"""
875+
mock_data_api = MagicMock()
876+
mock_data_api.delete_sandbox.return_value = {
877+
"code": "FAILED",
878+
"message": "",
879+
}
880+
mock_data_api_class.return_value = mock_data_api
881+
882+
client = SandboxClient()
883+
with pytest.raises(ClientError, match="Failed to stop sandbox"):
884+
client.delete_sandbox("sandbox-123")
836885

837886
@patch("agentrun.sandbox.client.SandboxControlAPI")
838887
@patch("agentrun.sandbox.client.SandboxDataAPI")
839888
@pytest.mark.asyncio
840-
async def test_delete_sandbox_async_not_found_in_response_is_idempotent(
889+
async def test_delete_sandbox_async_not_found_in_response_raises_resource_not_exist(
841890
self, mock_data_api_class, mock_control_api_class
842891
):
843-
"""数据面返回 not found 时,delete_sandbox_async 应幂等成功"""
892+
"""数据面业务层返回 not-found 时(async),与 HTTP 404 路径统一抛 ResourceNotExistError。"""
844893
mock_data_api = MagicMock()
845894
mock_data_api.delete_sandbox_async = AsyncMock(
846895
return_value={
@@ -851,8 +900,28 @@ async def test_delete_sandbox_async_not_found_in_response_is_idempotent(
851900
mock_data_api_class.return_value = mock_data_api
852901

853902
client = SandboxClient()
854-
result = await client.delete_sandbox_async("sandbox-123")
855-
assert result.sandbox_id == "sandbox-123"
903+
with pytest.raises(ResourceNotExistError):
904+
await client.delete_sandbox_async("sandbox-123")
905+
906+
@patch("agentrun.sandbox.client.SandboxControlAPI")
907+
@patch("agentrun.sandbox.client.SandboxDataAPI")
908+
@pytest.mark.asyncio
909+
async def test_delete_sandbox_async_other_failure_raises_client_error(
910+
self, mock_data_api_class, mock_control_api_class
911+
):
912+
"""无关 not-found 的失败消息(async)应仍抛 ClientError。"""
913+
mock_data_api = MagicMock()
914+
mock_data_api.delete_sandbox_async = AsyncMock(
915+
return_value={
916+
"code": "FAILED",
917+
"message": "sandbox is busy",
918+
}
919+
)
920+
mock_data_api_class.return_value = mock_data_api
921+
922+
client = SandboxClient()
923+
with pytest.raises(ClientError, match="Failed to stop sandbox"):
924+
await client.delete_sandbox_async("sandbox-123")
856925

857926
@patch("agentrun.sandbox.client.SandboxControlAPI")
858927
@patch("agentrun.sandbox.client.SandboxDataAPI")

0 commit comments

Comments
 (0)