Files
OpenShell/python/openshell/sandbox_cleanup_test.py
Mrunal Patel 39cf4823f7 feat(api): add structured gateway errors and SDK decoding (#3313)
* feat(api): expose structured gateway errors across SDKs

Refs #3051. Add standard validation, conflict, and retry details; preserve raw transport status in Rust, Go, TypeScript, and Python; document status and recovery guidance.

This is the structured-error foundation only. Mutation result shapes, allow_missing, durable request deduplication, and exec retry semantics remain follow-up work.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* fix(python): preserve wrapped RPC cleanup handling

Inspect the original gRPC call when handling missing sandboxes during deletion waits and managed cleanup. Add intercepted cleanup regressions and clarify the error-wrapper migration contract.

Addresses the cleanup review on #3313; part of #3051.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

---------

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
2026-09-15 17:51:11 +00:00

152 lines
4.9 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Cleanup status handling through the curated client's real gRPC interceptor."""
from concurrent import futures
from contextlib import nullcontext
from types import SimpleNamespace
import grpc
import pytest
from openshell._proto import openshell_pb2
from openshell.errors import GatewayError
from openshell.sandbox import Sandbox, SandboxClient
@pytest.fixture
def cleanup_client():
state = SimpleNamespace(
exists=False,
code=grpc.StatusCode.NOT_FOUND,
calls=[],
closed=[],
)
def fail(context):
context.set_trailing_metadata((("request-id", "cleanup-regression"),))
context.abort(state.code, "cleanup status")
def get(request, context):
state.calls.append("GetSandbox")
assert request.name == "cleanup-test"
assert request.workspace_scope.workspace == "default"
if not state.exists:
fail(context)
response = openshell_pb2.SandboxResponse()
response.sandbox.metadata.id = "sandbox-1"
response.sandbox.metadata.name = request.name
response.sandbox.metadata.workspace = "default"
response.sandbox.status.phase = openshell_pb2.SANDBOX_PHASE_READY
return response
def delete(request, context):
state.calls.append("DeleteSandbox")
assert request.name == "cleanup-test"
assert request.workspace_scope.workspace == "default"
fail(context)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
server.add_generic_rpc_handlers(
(
grpc.method_handlers_generic_handler(
"openshell.v1.OpenShell",
{
"GetSandbox": grpc.unary_unary_rpc_method_handler(
get,
request_deserializer=openshell_pb2.GetSandboxRequest.FromString,
response_serializer=openshell_pb2.SandboxResponse.SerializeToString,
),
"DeleteSandbox": grpc.unary_unary_rpc_method_handler(
delete,
request_deserializer=openshell_pb2.DeleteSandboxRequest.FromString,
response_serializer=openshell_pb2.DeleteSandboxResponse.SerializeToString,
),
},
),
)
)
port = server.add_insecure_port("127.0.0.1:0")
server.start()
client = SandboxClient(
f"127.0.0.1:{port}",
timeout=5,
_bearer_close=lambda: state.closed.append(True),
)
try:
yield client, state
finally:
client.close()
server.stop(0).wait()
def assert_original_call(error, code):
assert isinstance(error, GatewayError)
assert isinstance(error.raw_error, grpc.Call)
assert error.code() == error.raw_error.code() == code
assert error.details() == "cleanup status"
assert ("request-id", "cleanup-regression") in error.trailing_metadata()
@pytest.mark.parametrize(
"code",
[
grpc.StatusCode.NOT_FOUND,
grpc.StatusCode.PERMISSION_DENIED,
grpc.StatusCode.UNAVAILABLE,
],
)
def test_wait_deleted_handles_intercepted_status(cleanup_client, code):
client, state = cleanup_client
state.code = code
# Prove this is the curated intercepted stub, not a fake raw exception.
with pytest.raises(GatewayError) as observed:
client.get("cleanup-test", workspace="default")
assert_original_call(observed.value, code)
expected = (
nullcontext()
if code == grpc.StatusCode.NOT_FOUND
else pytest.raises(GatewayError)
)
with expected as caught:
client.wait_deleted("cleanup-test", workspace="default", timeout_seconds=5)
if caught is not None:
assert_original_call(caught.value, code)
assert state.calls == ["GetSandbox", "GetSandbox"]
@pytest.mark.parametrize(
"code",
[
grpc.StatusCode.NOT_FOUND,
grpc.StatusCode.PERMISSION_DENIED,
grpc.StatusCode.UNAVAILABLE,
],
)
def test_context_cleanup_handles_intercepted_status(cleanup_client, monkeypatch, code):
client, state = cleanup_client
state.code = code
state.exists = True
monkeypatch.setattr(
SandboxClient,
"from_active_cluster",
classmethod(lambda _cls, **_kwargs: client),
)
managed = Sandbox(workspace="default", sandbox="cleanup-test")
expected = (
nullcontext()
if code == grpc.StatusCode.NOT_FOUND
else pytest.raises(GatewayError)
)
with expected as caught, managed:
state.exists = False
if caught is not None:
assert_original_call(caught.value, code)
assert state.calls == ["GetSandbox", "GetSandbox", "DeleteSandbox"]
assert state.closed == [True]
assert managed._client is None
assert managed._session is None