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>
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Typed gateway errors that retain the original gRPC status and metadata."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import grpc
|
|
from google.protobuf.message import DecodeError
|
|
from google.rpc import error_details_pb2, status_pb2
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FieldViolation:
|
|
"""A request field rejected by the gateway."""
|
|
|
|
field: str
|
|
description: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ErrorInfo:
|
|
"""A machine-readable error reason scoped to its producing service."""
|
|
|
|
reason: str
|
|
domain: str
|
|
metadata: dict[str, str]
|
|
|
|
|
|
class GatewayError(grpc.RpcError):
|
|
"""A gRPC failure with decoded standard details.
|
|
|
|
``raw_error`` retains the complete original exception and metadata.
|
|
``raw_status`` retains all decoded Any messages, including unknown types.
|
|
A retry delay is guidance only; it does not make a mutation safe to repeat.
|
|
Existing ``except grpc.RpcError`` handlers continue to match this type.
|
|
"""
|
|
|
|
def __init__(self, error: grpc.RpcError) -> None:
|
|
super().__init__(str(error))
|
|
self.raw_error = error
|
|
self.raw_status: status_pb2.Status | None = None
|
|
self.field_violations: tuple[FieldViolation, ...] = ()
|
|
self.error_info: ErrorInfo | None = None
|
|
self.retry_delay: float | None = None
|
|
trailing = getattr(error, "trailing_metadata", lambda: None)() or ()
|
|
for key, value in trailing:
|
|
if key != "grpc-status-details-bin" or not isinstance(value, bytes):
|
|
continue
|
|
try:
|
|
status = status_pb2.Status.FromString(value)
|
|
except DecodeError:
|
|
continue
|
|
# The transport status is authoritative. Inconsistent rich status
|
|
# must not provide misleading recovery guidance.
|
|
code = self.code()
|
|
if (
|
|
code is None
|
|
or status.code != code.value[0]
|
|
or status.message != self.details()
|
|
):
|
|
continue
|
|
self.raw_status = status
|
|
break
|
|
if self.raw_status is None:
|
|
return
|
|
violations = []
|
|
for detail in self.raw_status.details:
|
|
try:
|
|
if detail.Is(error_details_pb2.BadRequest.DESCRIPTOR):
|
|
bad_request = error_details_pb2.BadRequest()
|
|
detail.Unpack(bad_request)
|
|
violations.extend(
|
|
FieldViolation(v.field, v.description)
|
|
for v in bad_request.field_violations
|
|
)
|
|
elif detail.Is(error_details_pb2.ErrorInfo.DESCRIPTOR):
|
|
info = error_details_pb2.ErrorInfo()
|
|
detail.Unpack(info)
|
|
self.error_info = ErrorInfo(
|
|
info.reason, info.domain, dict(info.metadata)
|
|
)
|
|
elif detail.Is(error_details_pb2.RetryInfo.DESCRIPTOR):
|
|
retry = error_details_pb2.RetryInfo()
|
|
detail.Unpack(retry)
|
|
delay = retry.retry_delay
|
|
if (
|
|
retry.HasField("retry_delay")
|
|
and 0 <= delay.seconds <= 315_576_000_000
|
|
and 0 <= delay.nanos < 1_000_000_000
|
|
):
|
|
self.retry_delay = delay.seconds + delay.nanos / 1_000_000_000
|
|
except DecodeError:
|
|
continue
|
|
self.field_violations = tuple(violations)
|
|
|
|
def code(self):
|
|
"""Return the original gRPC status code."""
|
|
return getattr(self.raw_error, "code", lambda: None)()
|
|
|
|
def details(self):
|
|
"""Return the original human-readable gRPC message."""
|
|
return getattr(self.raw_error, "details", lambda: str(self.raw_error))()
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self.raw_error, name)
|
|
|
|
|
|
def from_grpc_error(error: grpc.RpcError) -> GatewayError:
|
|
"""Decode a raw RPC failure without discarding unrecognized details."""
|
|
return error if isinstance(error, GatewayError) else GatewayError(error)
|
|
|
|
|
|
class _ErrorMappingStream:
|
|
def __init__(self, call):
|
|
self._call = call
|
|
self._iterator = iter(call)
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def __next__(self):
|
|
try:
|
|
return next(self._iterator)
|
|
except grpc.RpcError as error:
|
|
raise from_grpc_error(error) from error
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self._call, name)
|
|
|
|
|
|
class _ErrorMappingInterceptor(
|
|
grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor
|
|
):
|
|
def intercept_unary_unary(self, continuation, client_call_details, request):
|
|
call = continuation(client_call_details, request)
|
|
error = call.exception()
|
|
if isinstance(error, grpc.RpcError):
|
|
raise from_grpc_error(error) from error
|
|
return call
|
|
|
|
def intercept_unary_stream(self, continuation, client_call_details, request):
|
|
return _ErrorMappingStream(continuation(client_call_details, request))
|
|
|
|
|
|
def _error_mapping_channel(channel):
|
|
return grpc.intercept_channel(channel, _ErrorMappingInterceptor())
|