351 lines
12 KiB
Python

"""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