From 0b6edfc5284a0820a42d09ff54fe746e2dfffe0a Mon Sep 17 00:00:00 2001 From: Nicholas Pun <182540099+nicholaspun-wandb@users.noreply.github.com> Date: Sat, 23 May 2026 08:00:09 -0700 Subject: [PATCH] fix: surface bad request field violations --- src/cwsandbox/__init__.py | 8 ++ src/cwsandbox/_error_info.py | 89 ++++++++++++++++++-- src/cwsandbox/_network.py | 36 +++++++- src/cwsandbox/exceptions.py | 39 ++++++++- tests/unit/cwsandbox/test_error_info.py | 86 +++++++++++++++++++ tests/unit/cwsandbox/test_exceptions.py | 21 +++++ tests/unit/cwsandbox/test_network.py | 105 ++++++++++++++++++++++-- tests/unit/cwsandbox/test_sandbox.py | 37 ++++++++- 8 files changed, 401 insertions(+), 20 deletions(-) diff --git a/src/cwsandbox/__init__.py b/src/cwsandbox/__init__.py index 7ea48e2a..172fce76 100644 --- a/src/cwsandbox/__init__.py +++ b/src/cwsandbox/__init__.py @@ -54,7 +54,10 @@ AsyncFunctionError, CWSandboxAuthenticationError, CWSandboxError, + CWSandboxValidationError, DiscoveryError, + DiscoveryValidationError, + FieldViolation, FunctionError, ProfileNotFoundError, RunnerNotFoundError, @@ -74,6 +77,7 @@ SandboxTerminatedError, SandboxTimeoutError, SandboxUnavailableError, + SandboxValidationError, SnapshotBackendThrottledError, SnapshotBucketMismatchError, SnapshotNotFoundError, @@ -281,8 +285,11 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]: "AsyncFunctionError", "CWSandboxAuthenticationError", "CWSandboxError", + "CWSandboxValidationError", "DiscoveryError", + "DiscoveryValidationError", "EgressMode", + "FieldViolation", "FileSystemSnapshot", "FileSystemSnapshotBucketConfig", "FileSystemSnapshotBucketMode", @@ -326,6 +333,7 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]: "SandboxTerminatedError", "SandboxTimeoutError", "SandboxUnavailableError", + "SandboxValidationError", "SnapshotBackendThrottledError", "SnapshotBucketMismatchError", "SnapshotNotFoundError", diff --git a/src/cwsandbox/_error_info.py b/src/cwsandbox/_error_info.py index 5a9e679c..8efa1d36 100644 --- a/src/cwsandbox/_error_info.py +++ b/src/cwsandbox/_error_info.py @@ -2,14 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-PackageName: cwsandbox-client -"""AIP-193 ErrorInfo parser for gRPC trailing metadata. +"""AIP-193 structured-error parser for gRPC trailing metadata. Parses ``google.rpc.Status`` structured error details out of the ``grpc-status-details-bin`` trailing metadata entry produced by servers that follow Google's AIP-193 error model, and returns the subset of fields the SDK cares about (``ErrorInfo.reason``, ``ErrorInfo.domain``, ``ErrorInfo.metadata``, and -``RetryInfo.retry_delay`` when present). +``RetryInfo.retry_delay`` when present, and ``BadRequest`` field +violations). The parser is defensive: any failure to decode or extract details returns ``None`` rather than raising, so callers can safely fall @@ -27,6 +28,8 @@ from google.protobuf import message as _message from google.rpc import error_details_pb2, status_pb2 +from cwsandbox.exceptions import FieldViolation + _STATUS_DETAILS_KEY = "grpc-status-details-bin" # Domain value used by the CoreWeave backend when emitting ErrorInfo. @@ -161,12 +164,15 @@ class ParsedError: should shallow-copy via ``dict(parsed.metadata)``. retry_delay: The ``RetryInfo.retry_delay`` as a ``timedelta``, or ``None`` if no ``RetryInfo`` detail was present or the value was out of range. + field_violations: ``BadRequest.field_violations`` entries emitted by the + server. Empty tuple when no field violations were present. """ reason: str | None = None domain: str = "" metadata: Mapping[str, str] = field(default_factory=dict) retry_delay: timedelta | None = None + field_violations: tuple[FieldViolation, ...] = () def parse_error_info(err: grpc.RpcError) -> ParsedError | None: @@ -174,9 +180,9 @@ def parse_error_info(err: grpc.RpcError) -> ParsedError | None: Walks ``err.trailing_metadata()`` for every ``grpc-status-details-bin`` entry (the key may repeat in gRPC metadata). The first entry that decodes - to a ``google.rpc.Status`` containing at least one ``ErrorInfo`` or - ``RetryInfo`` detail wins; a malformed leading entry does not suppress a - later valid one. + to a ``google.rpc.Status`` containing at least one ``ErrorInfo``, + ``RetryInfo``, or ``BadRequest`` detail wins; a malformed leading entry does + not suppress a later valid one. Returns ``None`` when no entry yields usable structured details. Never raises - bad protobuf payloads, out-of-range RetryInfo durations, or any @@ -232,6 +238,7 @@ def _extract_parsed_error(status: status_pb2.Status) -> ParsedError | None: domain = "" metadata: Mapping[str, str] = {} retry_delay: timedelta | None = None + field_violations: list[FieldViolation] = [] for detail in status.details: # Treat an empty proto3-default `reason` as "not present" so a later @@ -258,16 +265,25 @@ def _extract_parsed_error(status: status_pb2.Status) -> ParsedError | None: if not retry.HasField("retry_delay"): continue retry_delay = _retry_delay_from_duration(retry.retry_delay) - if reason is not None and retry_delay is not None: - break + elif detail.Is(error_details_pb2.BadRequest.DESCRIPTOR): + bad_request = error_details_pb2.BadRequest() + try: + detail.Unpack(bad_request) + except _message.DecodeError: + continue + for violation in bad_request.field_violations: + parsed_violation = _field_violation_from_proto(violation) + if _has_field_violation_data(parsed_violation): + field_violations.append(parsed_violation) - if reason is None and retry_delay is None: + if reason is None and retry_delay is None and not field_violations: return None return ParsedError( reason=reason, domain=domain, metadata=metadata, retry_delay=retry_delay, + field_violations=tuple(field_violations), ) @@ -279,6 +295,63 @@ def _retry_delay_from_duration(duration: Any) -> timedelta | None: return result if isinstance(result, timedelta) else None +def _field_violation_from_proto( + violation: error_details_pb2.BadRequest.FieldViolation, +) -> FieldViolation: + return FieldViolation( + field=violation.field, + description=_field_violation_description_from_proto(violation), + ) + + +def _has_field_violation_data(violation: FieldViolation) -> bool: + return bool(violation.field or violation.description) + + +def _field_violation_description_from_proto( + violation: error_details_pb2.BadRequest.FieldViolation, +) -> str: + description = violation.description + if isinstance(description, str) and description: + return description + if violation.HasField("localized_message"): + message = violation.localized_message.message + if isinstance(message, str) and message: + return message + reason = violation.reason + return reason if isinstance(reason, str) else "" + + +def format_field_violations( + field_violations: Iterable[FieldViolation], +) -> str: + """Return a compact user-facing string for field violations.""" + messages: list[str] = [] + for violation in field_violations: + if violation.field and violation.description: + messages.append(f"{violation.field}: {violation.description}") + elif violation.field: + messages.append(violation.field) + elif violation.description: + messages.append(violation.description) + return "; ".join(messages) + + +def rpc_error_details(err: grpc.RpcError, parsed: ParsedError | None) -> str: + """Return gRPC details plus any parsed field violations.""" + details = err.details() + violation_details = ( + format_field_violations(parsed.field_violations) if parsed is not None else "" + ) + if details and violation_details: + return f"{details}; field violations: {violation_details}" + if details: + return details + if violation_details: + return f"field violations: {violation_details}" + return str(err) + + def is_not_found( err: grpc.RpcError, parsed: ParsedError | None, diff --git a/src/cwsandbox/_network.py b/src/cwsandbox/_network.py index c5953501..63546d22 100644 --- a/src/cwsandbox/_network.py +++ b/src/cwsandbox/_network.py @@ -22,8 +22,16 @@ import grpc.aio from cwsandbox._defaults import DEFAULT_GRPC_MAX_MESSAGE_LENGTH_BYTES -from cwsandbox._error_info import ParsedError, parse_error_info -from cwsandbox.exceptions import CWSandboxAuthenticationError, CWSandboxError +from cwsandbox._error_info import ParsedError, parse_error_info, rpc_error_details +from cwsandbox.exceptions import ( + CWSandboxAuthenticationError, + CWSandboxError, + CWSandboxValidationError, + DiscoveryError, + DiscoveryValidationError, + SandboxError, + SandboxValidationError, +) def parse_grpc_target(base_url: str) -> tuple[str, bool]: @@ -107,7 +115,8 @@ def translate_grpc_error( Any AIP-193 ``ErrorInfo`` / ``RetryInfo`` details present in the gRPC trailing metadata are parsed and attached to the returned exception (``reason``, ``metadata``, ``retry_delay``) regardless of which branch - picks the exception class. + picks the exception class. ``BadRequest`` field violations are attached + only to validation-specific exceptions. Args: e: The gRPC error to translate. @@ -132,6 +141,17 @@ def translate_grpc_error( reason = parsed.reason if parsed is not None else None metadata = parsed.metadata if parsed is not None else None retry_delay = parsed.retry_delay if parsed is not None else None + field_violations = parsed.field_violations if parsed is not None else () + + if code == grpc.StatusCode.INVALID_ARGUMENT and field_violations: + validation_cls = _validation_error_cls(fallback_cls) + return validation_cls( + f"{operation} failed: {rpc_error_details(e, parsed)}", + field_violations=field_violations, + reason=reason, + metadata=metadata, + retry_delay=retry_delay, + ) if code == grpc.StatusCode.UNAUTHENTICATED: return CWSandboxAuthenticationError( @@ -169,6 +189,16 @@ def translate_grpc_error( ) +def _validation_error_cls( + fallback_cls: type[CWSandboxError], +) -> type[CWSandboxValidationError]: + if issubclass(fallback_cls, SandboxError): + return SandboxValidationError + if issubclass(fallback_cls, DiscoveryError): + return DiscoveryValidationError + return CWSandboxValidationError + + async def paginate_async( rpc_method: Any, request: Any, diff --git a/src/cwsandbox/exceptions.py b/src/cwsandbox/exceptions.py index 3e6e9f54..dcd154d0 100644 --- a/src/cwsandbox/exceptions.py +++ b/src/cwsandbox/exceptions.py @@ -6,10 +6,11 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Mapping, Sequence from datetime import timedelta from cwsandbox._types import ProcessResult @@ -52,10 +53,42 @@ class CWSandboxAuthenticationError(CWSandboxError): """Raised when authentication fails.""" +@dataclass(frozen=True) +class FieldViolation: + """Field-level validation failure returned by the backend.""" + + field: str = "" + description: str = "" + + +class CWSandboxValidationError(CWSandboxError): + """Raised when the backend returns field-level validation failures. + + Attributes: + field_violations: Field-level validation errors returned by the backend. + """ + + def __init__( + self, + message: str, + *, + field_violations: Sequence[FieldViolation] | None = None, + reason: str | None = None, + metadata: Mapping[str, str] | None = None, + retry_delay: timedelta | None = None, + ) -> None: + super().__init__(message, reason=reason, metadata=metadata, retry_delay=retry_delay) + self.field_violations = tuple(field_violations) if field_violations else () + + class SandboxError(CWSandboxError): """Base exception for sandbox operations.""" +class SandboxValidationError(SandboxError, CWSandboxValidationError): + """Raised when a sandbox request fails field-level validation.""" + + class SandboxNotRunningError(SandboxError): """Raised when an operation requires a running sandbox.""" @@ -430,6 +463,10 @@ class DiscoveryError(CWSandboxError): """ +class DiscoveryValidationError(DiscoveryError, CWSandboxValidationError): + """Raised when a discovery request fails field-level validation.""" + + class RunnerNotFoundError(DiscoveryError): """Raised when a runner ID is not found. diff --git a/tests/unit/cwsandbox/test_error_info.py b/tests/unit/cwsandbox/test_error_info.py index c3a129ba..22a7bbc7 100644 --- a/tests/unit/cwsandbox/test_error_info.py +++ b/tests/unit/cwsandbox/test_error_info.py @@ -15,6 +15,7 @@ from google.rpc import error_details_pb2, status_pb2 from cwsandbox._error_info import ParsedError, parse_error_info +from cwsandbox.exceptions import FieldViolation def _pack_status( @@ -23,6 +24,7 @@ def _pack_status( metadata: dict[str, str] | None = None, retry_seconds: int | None = None, retry_nanos: int | None = None, + field_violations: list[tuple[str, str]] | None = None, ) -> bytes: """Build a serialized google.rpc.Status with the given details.""" status = status_pb2.Status(code=2, message="test") @@ -42,6 +44,18 @@ def _pack_status( packed = any_pb2.Any() packed.Pack(retry) status.details.append(packed) + if field_violations is not None: + bad_request = error_details_pb2.BadRequest() + for field, description in field_violations: + bad_request.field_violations.append( + error_details_pb2.BadRequest.FieldViolation( + field=field, + description=description, + ) + ) + packed = any_pb2.Any() + packed.Pack(bad_request) + status.details.append(packed) return status.SerializeToString() @@ -51,6 +65,7 @@ def _make_error( metadata: dict[str, str] | None = None, retry_seconds: int | None = None, retry_nanos: int | None = None, + field_violations: list[tuple[str, str]] | None = None, trailing: object | None | type = ..., # type: ignore[assignment] raise_on_trailing: Exception | None = None, no_trailing_method: bool = False, @@ -71,6 +86,7 @@ def _make_error( metadata=metadata, retry_seconds=retry_seconds, retry_nanos=retry_nanos, + field_violations=field_violations, ) err.trailing_metadata.return_value = [ ("some-other-key", "irrelevant"), @@ -87,6 +103,7 @@ def test_dataclass_defaults(self) -> None: assert parsed.reason is None assert parsed.metadata == {} assert parsed.retry_delay is None + assert parsed.field_violations == () class TestParseErrorInfo: @@ -132,6 +149,75 @@ def test_retry_info_without_error_info(self) -> None: assert parsed.reason is None assert parsed.retry_delay == timedelta(seconds=2) + def test_bad_request_field_violations_without_error_info(self) -> None: + err = _make_error( + field_violations=[ + ("tags[0]", "tags may contain letters, numbers, '.', '_' or '-'"), + ("tags[2]", "tags must be 59 characters or fewer"), + ] + ) + parsed = parse_error_info(err) + assert parsed is not None + assert parsed.reason is None + assert parsed.retry_delay is None + assert parsed.field_violations == ( + FieldViolation( + field="tags[0]", + description="tags may contain letters, numbers, '.', '_' or '-'", + ), + FieldViolation( + field="tags[2]", + description="tags must be 59 characters or fewer", + ), + ) + + def test_bad_request_uses_reason_and_localized_message_as_description_fallback(self) -> None: + status = status_pb2.Status(code=2, message="test") + bad_request = error_details_pb2.BadRequest() + bad_request.field_violations.append( + error_details_pb2.BadRequest.FieldViolation( + field="tags[0]", + reason="TAG_INVALID", + localized_message=error_details_pb2.LocalizedMessage( + locale="en-US", + message="Invalid tag", + ), + ) + ) + packed = any_pb2.Any() + packed.Pack(bad_request) + status.details.append(packed) + + err = _make_error(trailing=[("grpc-status-details-bin", status.SerializeToString())]) + parsed = parse_error_info(err) + + assert parsed is not None + assert parsed.field_violations == ( + FieldViolation( + field="tags[0]", + description="Invalid tag", + ), + ) + + def test_bad_request_with_error_info(self) -> None: + err = _make_error( + reason="CWSANDBOX_INVALID_ARGUMENT", + field_violations=[("tags[0]", "invalid tag")], + ) + parsed = parse_error_info(err) + assert parsed is not None + assert parsed.reason == "CWSANDBOX_INVALID_ARGUMENT" + assert parsed.field_violations == ( + FieldViolation( + field="tags[0]", + description="invalid tag", + ), + ) + + def test_empty_bad_request_returns_none(self) -> None: + err = _make_error(field_violations=[]) + assert parse_error_info(err) is None + def test_no_trailing_metadata_method(self) -> None: err = _make_error(no_trailing_method=True) assert parse_error_info(err) is None diff --git a/tests/unit/cwsandbox/test_exceptions.py b/tests/unit/cwsandbox/test_exceptions.py index 263fc242..a469d755 100644 --- a/tests/unit/cwsandbox/test_exceptions.py +++ b/tests/unit/cwsandbox/test_exceptions.py @@ -11,6 +11,10 @@ AsyncFunctionError, CWSandboxAuthenticationError, CWSandboxError, + CWSandboxValidationError, + DiscoveryError, + DiscoveryValidationError, + FieldViolation, FunctionError, SandboxError, SandboxExecutionError, @@ -24,6 +28,7 @@ SandboxTerminatedError, SandboxTimeoutError, SandboxUnavailableError, + SandboxValidationError, SnapshotBackendThrottledError, SnapshotBucketMismatchError, SnapshotNotFoundError, @@ -42,7 +47,9 @@ def test_cwsandbox_error_is_base_for_all_exceptions(self) -> None: """Test CWSandboxError is the base for all exceptions.""" assert issubclass(SandboxError, CWSandboxError) assert issubclass(FunctionError, CWSandboxError) + assert issubclass(DiscoveryError, CWSandboxError) assert issubclass(CWSandboxAuthenticationError, CWSandboxError) + assert issubclass(CWSandboxValidationError, CWSandboxError) def test_sandbox_error_is_base_for_sandbox_exceptions(self) -> None: """Test SandboxError is the base for sandbox-related exceptions.""" @@ -53,6 +60,12 @@ def test_sandbox_error_is_base_for_sandbox_exceptions(self) -> None: assert issubclass(SandboxFailedError, SandboxError) assert issubclass(SandboxExecutionError, SandboxError) assert issubclass(SandboxFileError, SandboxError) + assert issubclass(SandboxValidationError, SandboxError) + assert issubclass(SandboxValidationError, CWSandboxValidationError) + + def test_discovery_validation_error_is_discovery_error(self) -> None: + assert issubclass(DiscoveryValidationError, DiscoveryError) + assert issubclass(DiscoveryValidationError, CWSandboxValidationError) def test_function_error_is_base_for_function_exceptions(self) -> None: """Test FunctionError is the base for function-related exceptions.""" @@ -240,6 +253,14 @@ def test_base_accepts_structured_fields(self) -> None: assert exc.metadata == {"filepath": "/x"} assert exc.retry_delay == timedelta(seconds=3) + def test_validation_error_carries_field_violations(self) -> None: + violation = FieldViolation( + field="tags[0]", + description="invalid tag", + ) + exc = CWSandboxValidationError("invalid", field_violations=[violation]) + assert exc.field_violations == (violation,) + def test_metadata_is_copied(self) -> None: original = {"filepath": "/x"} exc = CWSandboxError("boom", metadata=original) diff --git a/tests/unit/cwsandbox/test_network.py b/tests/unit/cwsandbox/test_network.py index 1a018430..daaca05e 100644 --- a/tests/unit/cwsandbox/test_network.py +++ b/tests/unit/cwsandbox/test_network.py @@ -25,23 +25,44 @@ CWSandboxAuthenticationError, CWSandboxError, DiscoveryError, + DiscoveryValidationError, + FieldViolation, SandboxError, + SandboxValidationError, ) -def _status_bytes(reason: str, metadata: dict[str, str] | None = None, retry: int = 0) -> bytes: +def _status_bytes( + reason: str | None, + metadata: dict[str, str] | None = None, + retry: int = 0, + field_violations: list[tuple[str, str]] | None = None, +) -> bytes: status = status_pb2.Status(code=2, message="test") - info = error_details_pb2.ErrorInfo( - reason=reason, domain="cwsandbox.com", metadata=metadata or {} - ) - packed = any_pb2.Any() - packed.Pack(info) - status.details.append(packed) + if reason is not None: + info = error_details_pb2.ErrorInfo( + reason=reason, domain="cwsandbox.com", metadata=metadata or {} + ) + packed = any_pb2.Any() + packed.Pack(info) + status.details.append(packed) if retry: retry_detail = error_details_pb2.RetryInfo(retry_delay=Duration(seconds=retry)) packed_retry = any_pb2.Any() packed_retry.Pack(retry_detail) status.details.append(packed_retry) + if field_violations is not None: + bad_request = error_details_pb2.BadRequest() + for field, description in field_violations: + bad_request.field_violations.append( + error_details_pb2.BadRequest.FieldViolation( + field=field, + description=description, + ) + ) + packed_bad_request = any_pb2.Any() + packed_bad_request.Pack(bad_request) + status.details.append(packed_bad_request) return status.SerializeToString() @@ -302,6 +323,76 @@ def test_no_error_info_keeps_defaults(self) -> None: assert result.metadata == {} assert result.retry_delay is None + def test_field_violations_are_attached_and_rendered_when_details_empty(self) -> None: + err = _make_rpc_error( + grpc.StatusCode.INVALID_ARGUMENT, + "", + trailing=[ + ( + "grpc-status-details-bin", + _status_bytes( + None, + field_violations=[ + ("tags[0]", "invalid tag"), + ("tags[2]", "tag too long"), + ], + ), + ) + ], + ) + result = translate_grpc_error(err, operation="Start sandbox", fallback_cls=SandboxError) + assert isinstance(result, SandboxValidationError) + assert isinstance(result, SandboxError) + assert "Start sandbox failed: field violations:" in str(result) + assert "tags[0]: invalid tag" in str(result) + assert "tags[2]: tag too long" in str(result) + assert result.field_violations == ( + FieldViolation( + field="tags[0]", + description="invalid tag", + ), + FieldViolation( + field="tags[2]", + description="tag too long", + ), + ) + + def test_field_violations_are_rendered_with_existing_details(self) -> None: + err = _make_rpc_error( + grpc.StatusCode.INVALID_ARGUMENT, + "invalid tags", + trailing=[ + ( + "grpc-status-details-bin", + _status_bytes(None, field_violations=[("tags[0]", "invalid tag")]), + ) + ], + ) + result = translate_grpc_error(err, operation="Start sandbox", fallback_cls=SandboxError) + assert isinstance(result, SandboxValidationError) + assert "invalid tags; field violations: tags[0]: invalid tag" in str(result) + + def test_field_violations_use_discovery_validation_error(self) -> None: + err = _make_rpc_error( + grpc.StatusCode.INVALID_ARGUMENT, + "", + trailing=[ + ( + "grpc-status-details-bin", + _status_bytes(None, field_violations=[("runner_id", "invalid")]), + ) + ], + ) + result = translate_grpc_error(err, operation="Get runner", fallback_cls=DiscoveryError) + assert isinstance(result, DiscoveryValidationError) + assert isinstance(result, DiscoveryError) + assert result.field_violations == ( + FieldViolation( + field="runner_id", + description="invalid", + ), + ) + # --------------------------------------------------------------------------- # Pagination tests diff --git a/tests/unit/cwsandbox/test_sandbox.py b/tests/unit/cwsandbox/test_sandbox.py index d822a92b..b2661722 100644 --- a/tests/unit/cwsandbox/test_sandbox.py +++ b/tests/unit/cwsandbox/test_sandbox.py @@ -26,11 +26,13 @@ _Terminal, ) from cwsandbox.exceptions import ( + FieldViolation, SandboxError, SandboxFileError, SandboxNotFoundError, SandboxNotRunningError, SandboxResourceExhaustedError, + SandboxValidationError, ) @@ -5178,12 +5180,13 @@ def __init__( domain: str = "cwsandbox.com", metadata: dict[str, str] | None = None, retry_seconds: int = 0, + field_violations: list[tuple[str, str]] | None = None, ) -> None: super().__init__() self._code = code self._details = details self._trailing: list[tuple[str, bytes]] = [] - if reason is not None or retry_seconds: + if reason is not None or retry_seconds or field_violations is not None: from google.protobuf import any_pb2 from google.protobuf.duration_pb2 import Duration from google.rpc import error_details_pb2, status_pb2 @@ -5201,6 +5204,18 @@ def __init__( packed_retry = any_pb2.Any() packed_retry.Pack(retry) status.details.append(packed_retry) + if field_violations is not None: + bad_request = error_details_pb2.BadRequest() + for field, description in field_violations: + bad_request.field_violations.append( + error_details_pb2.BadRequest.FieldViolation( + field=field, + description=description, + ) + ) + packed_bad_request = any_pb2.Any() + packed_bad_request.Pack(bad_request) + status.details.append(packed_bad_request) self._trailing = [("grpc-status-details-bin", status.SerializeToString())] def code(self) -> grpc.StatusCode: @@ -5367,6 +5382,26 @@ def test_no_error_info_backward_compat(self) -> None: assert result.reason is None assert result.metadata == {} + def test_invalid_argument_bad_request_field_violations_are_rendered(self) -> None: + from cwsandbox._sandbox import _translate_rpc_error + + error = _MockRpcErrorWithDetails( + grpc.StatusCode.INVALID_ARGUMENT, + "", + field_violations=[("tags[0]", "invalid tag")], + ) + result = _translate_rpc_error(error, operation="Start sandbox") + + assert isinstance(result, SandboxValidationError) + assert isinstance(result, SandboxError) + assert "Start sandbox failed: field violations: tags[0]: invalid tag" in str(result) + assert result.field_violations == ( + FieldViolation( + field="tags[0]", + description="invalid tag", + ), + ) + def test_file_reason_without_filepath_or_metadata(self) -> None: from cwsandbox._sandbox import _translate_rpc_error from cwsandbox.exceptions import SandboxFileError