"""Shared helper functions for error handling. These functions are used by both sync and async clients to parse error responses and raise appropriate exceptions. They contain no I/O operations. """ from __future__ import annotations from collections.abc import Mapping from typing import Any, Optional import httpx from langsmith.sandbox._exceptions import ( QuotaExceededError, ResourceCreationError, ResourceNotFoundError, ResourceTimeoutError, SandboxAPIError, SandboxAuthenticationError, SandboxClientError, SandboxConnectionError, SandboxNotReadyError, SandboxOperationError, ValidationError, ) # ============================================================================= # Header Utilities # ============================================================================= def merge_headers( base_headers: Optional[Mapping[str, str]] = None, override_headers: Optional[Mapping[str, str]] = None, ) -> dict[str, str]: """Merge request headers, giving precedence to overrides.""" merged: dict[str, str] = dict(base_headers or {}) if override_headers: merged.update(override_headers) return merged # ============================================================================= # Input Validation # ============================================================================= def validate_service_params(port: int, expires_in_seconds: int) -> None: """Validate parameters for service URL generation. Args: port: Target port inside the sandbox. expires_in_seconds: Token TTL. Raises: ValueError: If port or TTL is out of range. """ if not isinstance(port, int) or port <= 0: raise ValueError(f"port must be a positive integer, got {port!r}") if not isinstance(expires_in_seconds, int) or not ( 1 <= expires_in_seconds <= 86400 ): raise ValueError( f"expires_in_seconds must be between 1 and 86400, " f"got {expires_in_seconds!r}" ) def validate_ttl(value: Optional[int], name: str) -> None: """Validate a TTL value for sandbox create/update. Args: value: TTL in seconds (None means unset, 0 disables). name: Parameter name for error messages. Raises: ValueError: If value is negative or not a multiple of 60. """ if value is None: return if value < 0: raise ValueError(f"{name} must be >= 0, got {value}") if value != 0 and value % 60 != 0: raise ValueError(f"{name} must be a multiple of 60 seconds, got {value}") # ============================================================================= # Error Response Parsing # ============================================================================= def parse_error_response(error: httpx.HTTPStatusError) -> dict[str, Any]: """Parse standardized error response. Expected format: {"detail": {"error": "...", "message": "..."}} Returns a dict with: - error_type: The error type (e.g., "ImagePull", "CrashLoop") - message: Human-readable error message """ try: data = error.response.json() detail = data.get("detail") # Standardized format: {"detail": {"error": "...", "message": "..."}} if isinstance(detail, dict): return { "error_type": detail.get("error"), "message": detail.get("message", str(error)), } # Pydantic validation error format: {"detail": [{"loc": [...], "msg": "..."}]} if isinstance(detail, list) and detail: messages = [d.get("msg", str(d)) for d in detail if isinstance(d, dict)] return { "error_type": None, "message": "; ".join(messages) if messages else str(error), } # Fallback for plain string detail return {"error_type": None, "message": detail or str(error)} except Exception: return {"error_type": None, "message": str(error)} def parse_error_response_simple(error: httpx.HTTPStatusError) -> dict[str, Any]: """Parse error response (simplified version for sandbox operations). Returns a dict with: - error_type: The error type - message: Human-readable error message """ try: data = error.response.json() detail = data.get("detail") if isinstance(detail, dict): return { "error_type": detail.get("error"), "message": detail.get("message", str(error)), } return {"error_type": None, "message": detail or str(error)} except Exception: return {"error_type": None, "message": str(error)} def parse_validation_error(error: httpx.HTTPStatusError) -> list[dict]: """Parse Pydantic validation error response. Returns a list of validation error details, each containing: - loc: Location of the error (e.g., ["body", "resources", "cpu"]) - msg: Human-readable error message - type: Error type (e.g., "value_error") """ try: data = error.response.json() detail = data.get("detail", []) if isinstance(detail, list): return detail return [] except Exception: return [] def extract_quota_type(message: str) -> Optional[str]: """Extract quota type from error message. Returns one of: "sandbox_count", "cpu", "memory", "storage", or None. """ message_lower = message.lower() # Check for sandbox count quota if "sandbox" in message_lower and ( "count" in message_lower or "limit" in message_lower ): return "sandbox_count" elif "cpu" in message_lower: return "cpu" elif "memory" in message_lower: return "memory" elif "storage" in message_lower: return "storage" return None # ============================================================================= # Client Error Handlers # ============================================================================= def raise_creation_error( data: dict[str, Any], error: httpx.HTTPStatusError, resource_type: str = "sandbox", ) -> None: """Raise ResourceCreationError with the error_type from the API response. The error_type indicates the specific failure reason: - ImagePull: Image pull failed - CrashLoop: Container crashed during startup - SandboxConfig: Configuration error - Unschedulable: Cannot be scheduled """ raise ResourceCreationError( data.get("message", f"{resource_type.title()} creation failed"), resource_type=resource_type, error_type=data.get("error_type"), ) from error def handle_sandbox_creation_error(error: httpx.HTTPStatusError) -> None: """Handle HTTP errors specific to sandbox creation. Maps API error responses to specific exception types: - 408: ResourceTimeoutError (sandbox didn't become ready in time) - 422: ValidationError (bad input) or ResourceCreationError (runtime) - 429: QuotaExceededError (org limits exceeded) - 503: ResourceCreationError (no resources available) - Other: Falls through to generic error handling """ status = error.response.status_code data = parse_error_response(error) if status == 408: # Timeout - include the message which contains last known status raise ResourceTimeoutError(data["message"], resource_type="sandbox") from error elif status == 422: # Check if this is a Pydantic validation error (bad input) vs creation error details = parse_validation_error(error) if details and any(d.get("type") == "value_error" for d in details): # Pydantic validation error (bad input - exceeds server limits) field = details[0].get("loc", [None])[-1] if details else None raise ValidationError( message=data["message"], field=field, details=details, ) from error else: # Sandbox creation failed (runtime error like image pull failure) raise_creation_error(data, error) elif status == 429: # Organization quota exceeded quota_type = extract_quota_type(data["message"]) raise QuotaExceededError( message=data["message"], quota_type=quota_type, ) from error elif status == 503: # Service Unavailable - scheduling failed raise ResourceCreationError( data["message"], resource_type="sandbox", error_type=data.get("error_type") or "Unschedulable", ) from error else: # Fall through to generic handling handle_client_http_error(error) def handle_client_http_error(error: httpx.HTTPStatusError) -> None: """Handle HTTP errors and raise appropriate exceptions (for client operations).""" data = parse_error_response(error) message = data["message"] error_type = data.get("error_type") status = error.response.status_code if status in (401, 403): raise SandboxAuthenticationError(message) from error if status == 404: raise ResourceNotFoundError(message) from error # Handle validation errors (invalid resource values, formats, etc.) if status == 422: details = parse_validation_error(error) field = details[0].get("loc", [None])[-1] if details else None raise ValidationError( message=message, field=field, details=details, ) from error # Handle quota exceeded errors (org limits) if status == 429: quota_type = extract_quota_type(message) raise QuotaExceededError( message=message, quota_type=quota_type, ) from error if status == 502 and error_type == "ConnectionError": raise SandboxConnectionError(message) from error if status == 500: raise SandboxAPIError(message) from error raise SandboxClientError(message) from error # ============================================================================= # Sandbox Operation Error Handlers # ============================================================================= def handle_sandbox_http_error(error: httpx.HTTPStatusError) -> None: """Handle HTTP errors for sandbox operations (run, read, write). Maps API error types to specific exceptions: - WriteError -> SandboxOperationError (operation="write") - ReadError -> SandboxOperationError (operation="read") - CommandError -> SandboxOperationError (operation="command") - ConnectionError (502) -> SandboxConnectionError - FileNotFound / 404 -> ResourceNotFoundError (resource_type="file") - NotReady (400) -> SandboxNotReadyError - 403 -> SandboxOperationError (permission denied) """ data = parse_error_response_simple(error) message = data["message"] error_type = data.get("error_type") status = error.response.status_code # Operation-specific errors (from sandbox runtime) if error_type == "WriteError": raise SandboxOperationError( message, operation="write", error_type=error_type ) from error if error_type == "ReadError": raise SandboxOperationError( message, operation="read", error_type=error_type ) from error if error_type == "CommandError": raise SandboxOperationError( message, operation="command", error_type=error_type ) from error # Permission denied if status == 403: raise SandboxOperationError( message, operation=None, error_type="PermissionDenied" ) from error # Connection to sandbox failed if status == 502 and error_type == "ConnectionError": raise SandboxConnectionError(message) from error # Not ready / not found if status == 400 and error_type == "NotReady": raise SandboxNotReadyError(message) from error if status == 404 or error_type == "FileNotFound": raise ResourceNotFoundError(message, resource_type="file") from error raise SandboxClientError(message) from error