mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
* 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>
128 lines
4.2 KiB
Python
128 lines
4.2 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Rich error decoding and preservation across unary and streaming calls."""
|
|
|
|
from concurrent import futures
|
|
|
|
import grpc
|
|
import pytest
|
|
from google.rpc import error_details_pb2, status_pb2
|
|
|
|
from openshell.errors import GatewayError, _error_mapping_channel, from_grpc_error
|
|
|
|
|
|
def rich_status():
|
|
status = status_pb2.Status(
|
|
code=grpc.StatusCode.UNAVAILABLE.value[0], message="try later"
|
|
)
|
|
retry = error_details_pb2.RetryInfo()
|
|
retry.retry_delay.seconds = 1
|
|
retry.retry_delay.nanos = 250_000_000
|
|
for message in [
|
|
error_details_pb2.BadRequest(
|
|
field_violations=[
|
|
error_details_pb2.BadRequest.FieldViolation(
|
|
field="name", description="invalid name"
|
|
),
|
|
]
|
|
),
|
|
error_details_pb2.ErrorInfo(
|
|
reason="GATEWAY_NOT_READY",
|
|
domain="openshell.nvidia.com",
|
|
metadata={"scope": "test"},
|
|
),
|
|
retry,
|
|
]:
|
|
detail = status.details.add()
|
|
detail.Pack(message)
|
|
status.details.add(
|
|
type_url="type.googleapis.com/future.ErrorDetail", value=b"\x08\x01"
|
|
)
|
|
return status
|
|
|
|
|
|
@pytest.mark.parametrize("streaming", [False, True])
|
|
def test_maps_real_rpc_errors_without_losing_details(streaming):
|
|
status = rich_status()
|
|
|
|
def fail(_request, context):
|
|
context.set_trailing_metadata(
|
|
(
|
|
("grpc-status-details-bin", status.SerializeToString()),
|
|
("request-id", "test-correlation"),
|
|
)
|
|
)
|
|
context.abort(grpc.StatusCode.UNAVAILABLE, "try later")
|
|
|
|
server = grpc.server(futures.ThreadPoolExecutor(max_workers=1))
|
|
handler = (
|
|
grpc.unary_stream_rpc_method_handler
|
|
if streaming
|
|
else grpc.unary_unary_rpc_method_handler
|
|
)
|
|
server.add_generic_rpc_handlers(
|
|
(grpc.method_handlers_generic_handler("test.Errors", {"Fail": handler(fail)}),)
|
|
)
|
|
port = server.add_insecure_port("127.0.0.1:0")
|
|
server.start()
|
|
try:
|
|
with _error_mapping_channel(
|
|
grpc.insecure_channel(f"127.0.0.1:{port}")
|
|
) as channel:
|
|
call = channel.unary_stream if streaming else channel.unary_unary
|
|
with pytest.raises(GatewayError) as caught:
|
|
response = call("/test.Errors/Fail")(b"", timeout=5)
|
|
if streaming:
|
|
list(response)
|
|
error = caught.value
|
|
assert error.code() == grpc.StatusCode.UNAVAILABLE
|
|
assert error.details() == "try later"
|
|
assert error.field_violations[0].field == "name"
|
|
assert error.error_info is not None
|
|
assert error.error_info.reason == "GATEWAY_NOT_READY"
|
|
assert error.retry_delay == 1.25
|
|
assert error.raw_status == status
|
|
assert ("request-id", "test-correlation") in error.trailing_metadata()
|
|
assert from_grpc_error(error) is error
|
|
finally:
|
|
server.stop(0).wait()
|
|
|
|
|
|
class RawError(grpc.RpcError):
|
|
def __init__(self, details):
|
|
self._details = details
|
|
|
|
def code(self):
|
|
return grpc.StatusCode.UNAVAILABLE
|
|
|
|
def details(self):
|
|
return "try later"
|
|
|
|
def trailing_metadata(self):
|
|
return [("grpc-status-details-bin", self._details)]
|
|
|
|
|
|
def test_malformed_or_inconsistent_details_preserve_raw_error():
|
|
for payload in [
|
|
b"\xff",
|
|
status_pb2.Status(code=3, message="different").SerializeToString(),
|
|
]:
|
|
raw = RawError(payload)
|
|
error = from_grpc_error(raw)
|
|
assert error.raw_error is raw
|
|
assert error.raw_status is None
|
|
assert error.retry_delay is None
|
|
assert error.trailing_metadata() == raw.trailing_metadata()
|
|
|
|
|
|
def test_malformed_known_detail_does_not_hide_other_details():
|
|
status = rich_status()
|
|
status.details.add(
|
|
type_url="type.googleapis.com/google.rpc.BadRequest", value=b"\xff"
|
|
)
|
|
error = from_grpc_error(RawError(status.SerializeToString()))
|
|
assert error.field_violations[0].field == "name"
|
|
assert error.raw_status is not None
|
|
assert len(error.raw_status.details) == 5
|