Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/cwsandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@
AsyncFunctionError,
CWSandboxAuthenticationError,
CWSandboxError,
CWSandboxValidationError,
DiscoveryError,
DiscoveryValidationError,
FieldViolation,
FunctionError,
ProfileNotFoundError,
RunnerNotFoundError,
Expand All @@ -74,6 +77,7 @@
SandboxTerminatedError,
SandboxTimeoutError,
SandboxUnavailableError,
SandboxValidationError,
SnapshotBackendThrottledError,
SnapshotBucketMismatchError,
SnapshotNotFoundError,
Expand Down Expand Up @@ -281,8 +285,11 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]:
"AsyncFunctionError",
"CWSandboxAuthenticationError",
"CWSandboxError",
"CWSandboxValidationError",
"DiscoveryError",
"DiscoveryValidationError",
"EgressMode",
"FieldViolation",
"FileSystemSnapshot",
"FileSystemSnapshotBucketConfig",
"FileSystemSnapshotBucketMode",
Expand Down Expand Up @@ -326,6 +333,7 @@ def _to_awaitable(w: Waitable) -> asyncio.Task[Any]:
"SandboxTerminatedError",
"SandboxTimeoutError",
"SandboxUnavailableError",
"SandboxValidationError",
"SnapshotBackendThrottledError",
"SnapshotBucketMismatchError",
"SnapshotNotFoundError",
Expand Down
89 changes: 81 additions & 8 deletions src/cwsandbox/_error_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -161,22 +164,25 @@ 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:
"""Parse ``google.rpc.Status`` details from gRPC trailing metadata.

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
Expand Down Expand Up @@ -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
Expand All @@ -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),
)


Expand All @@ -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,
Expand Down
36 changes: 33 additions & 3 deletions src/cwsandbox/_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion src/cwsandbox/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading