mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
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>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# 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())
|
||||
Reference in New Issue
Block a user