369 lines
13 KiB
Python
369 lines
13 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 typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from langsmith.sandbox._exceptions import (
|
|
QuotaExceededError,
|
|
ResourceAlreadyExistsError,
|
|
ResourceCreationError,
|
|
ResourceNotFoundError,
|
|
ResourceTimeoutError,
|
|
SandboxAPIError,
|
|
SandboxAuthenticationError,
|
|
SandboxClientError,
|
|
SandboxConnectionError,
|
|
SandboxNotReadyError,
|
|
SandboxOperationError,
|
|
ValidationError,
|
|
)
|
|
|
|
# =============================================================================
|
|
# 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", "volume_count",
|
|
"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"
|
|
# Check for volume count quota
|
|
elif "volume" in message_lower and (
|
|
"count" in message_lower or "limit" in message_lower
|
|
):
|
|
return "volume_count"
|
|
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_volume_creation_error(error: httpx.HTTPStatusError) -> None:
|
|
"""Handle HTTP errors specific to volume creation.
|
|
|
|
Maps API error responses to specific exception types:
|
|
- 503: ResourceCreationError (provisioning failed)
|
|
- 504: ResourceTimeoutError (volume didn't become ready in time)
|
|
- Other: Falls through to generic error handling
|
|
"""
|
|
status = error.response.status_code
|
|
data = parse_error_response(error)
|
|
|
|
if status == 503:
|
|
# Provisioning failed (invalid storage class, quota exceeded)
|
|
raise ResourceCreationError(
|
|
data["message"],
|
|
resource_type="volume",
|
|
error_type="VolumeProvisioning",
|
|
) from error
|
|
elif status == 504:
|
|
# Timeout - volume didn't become ready in time
|
|
raise ResourceTimeoutError(data["message"], resource_type="volume") from error
|
|
else:
|
|
# Fall through to generic handling
|
|
handle_client_http_error(error)
|
|
|
|
|
|
def handle_pool_error(error: httpx.HTTPStatusError) -> None:
|
|
"""Handle HTTP errors specific to pool creation/update.
|
|
|
|
Maps API error responses to specific exception types:
|
|
- 400: ResourceNotFoundError or ValidationError (template has volumes)
|
|
- 409: ResourceAlreadyExistsError
|
|
- 429: QuotaExceededError (org limits exceeded)
|
|
- 504: ResourceTimeoutError (timeout waiting for ready replicas)
|
|
- Other: Falls through to generic error handling
|
|
"""
|
|
status = error.response.status_code
|
|
data = parse_error_response(error)
|
|
error_type = data.get("error_type")
|
|
|
|
if status == 400:
|
|
# Check the error type to determine the specific exception
|
|
if error_type == "TemplateNotFound":
|
|
raise ResourceNotFoundError(
|
|
data["message"], resource_type="template"
|
|
) from error
|
|
elif error_type == "ValidationError":
|
|
# Template has volumes attached
|
|
raise ValidationError(data["message"], error_type=error_type) from error
|
|
else:
|
|
# Generic bad request
|
|
handle_client_http_error(error)
|
|
elif status == 409:
|
|
# Pool already exists
|
|
raise ResourceAlreadyExistsError(
|
|
data["message"], resource_type="pool"
|
|
) from 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 == 504:
|
|
# Timeout waiting for pool to be ready
|
|
raise ResourceTimeoutError(data["message"], resource_type="pool") 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
|