# Error Handling Conventions

## Overview

The SDK uses a hierarchy of exceptions that are informative, actionable, and easy to handle. This document defines error patterns and best practices.

> **Note**: Use `@error-handling-review` agent to verify exception usage and error messages follow these patterns.

## Exception Hierarchy

### Base Exception

```python
class PineconeError(Exception):
    """Base exception for all Pinecone SDK errors."""

    def __init__(self, message: str):
        self.message = message
        super().__init__(message)
```

### Exception Tree

```
PineconeError
├── APIConnectionError       # Network/connection issues
│   ├── TimeoutError
│   └── ConnectionError
├── APIError                 # Server returned error
│   ├── BadRequestError      # 400
│   ├── UnauthorizedError    # 401
│   ├── ForbiddenError       # 403
│   ├── NotFoundError        # 404
│   ├── RateLimitError       # 429
│   └── ServerError          # 500+
└── ConfigurationError       # Invalid configuration
    ├── InvalidAPIKeyError
    └── InvalidConfigError
```

## Error Classes

### Network Errors

**For connection failures**:

```python
class APIConnectionError(PineconeError):
    """Failed to connect to Pinecone API."""
    pass

class TimeoutError(APIConnectionError):
    """Request timed out."""

    def __init__(self, timeout: float):
        super().__init__(f"Request timed out after {timeout}s")
        self.timeout = timeout

class ConnectionError(APIConnectionError):
    """Connection failed."""

    def __init__(self, message: str, cause: Exception | None = None):
        super().__init__(message)
        self.cause = cause
```

### API Errors

**For server-returned errors**:

```python
class APIError(PineconeError):
    """Server returned an error response.
    
    Attributes:
        message: Human-readable error message
        status_code: HTTP status code
        response_body: Raw response body
        request_id: Request ID for support/debugging
        error_code: API-specific error code
        url: URL that was requested
        method: HTTP method used
    """

    def __init__(
        self,
        message: str,
        status_code: int,
        response_body: str | None = None,
        *,
        request_id: str | None = None,
        error_code: str | None = None,
        url: str | None = None,
        method: str | None = None,
    ):
        super().__init__(message)
        self.status_code = status_code
        self.response_body = response_body
        self.request_id = request_id
        self.error_code = error_code
        self.url = url
        self.method = method

class BadRequestError(APIError):
    """400 Bad Request."""

    def __init__(
        self,
        message: str | None = None,
        response_body: str | None = None,
        **kwargs,
    ):
        if message is None:
            message = "Bad request. Check your request parameters."
        super().__init__(message, 400, response_body, **kwargs)

class UnauthorizedError(APIError):
    """401 Unauthorized - invalid API key."""

    def __init__(self, message: str | None = None, **kwargs):
        if message is None:
            message = (
                "Invalid API key. Verify your API key is correct and active. "
                "Get your API key from https://app.pinecone.io"
            )
        super().__init__(message, 401, **kwargs)

class RateLimitError(APIError):
    """429 Rate Limit Exceeded."""

    def __init__(
        self,
        retry_after: int | None = None,
        message: str | None = None,
        **kwargs,
    ):
        if message is None:
            message = "Rate limit exceeded"
            if retry_after:
                message += f". Retry after {retry_after}s"
        super().__init__(message, 429, **kwargs)
        self.retry_after = retry_after
```

### Configuration Errors

**For invalid user input**:

```python
class ConfigurationError(PineconeError):
    """Invalid configuration."""
    pass

class InvalidAPIKeyError(ConfigurationError):
    """API key is missing or malformed."""

    def __init__(self):
        super().__init__(
            "API key is required. Set PINECONE_API_KEY or pass api_key parameter."
        )

class InvalidConfigError(ConfigurationError):
    """Configuration value is invalid."""

    def __init__(self, field: str, value: Any, reason: str):
        super().__init__(
            f"Invalid {field}={value!r}: {reason}"
        )
        self.field = field
        self.value = value
```

## Error Messages

### Message Patterns

**Be specific and actionable**:

```python
# ✅ Good: Specific, actionable
raise InvalidAPIKeyError()
# "API key is required. Set PINECONE_API_KEY or pass api_key parameter."

raise NotFoundError(f"Index '{name}' not found. Use client.list_indexes() to see available indexes.")

# ❌ Bad: Vague, not actionable
raise PineconeError("Error")
raise NotFoundError("Not found")
raise APIError("Something went wrong")
```

### Include Context

**Add relevant details**:

```python
# ✅ Good: Context included
raise TimeoutError(timeout=30.0)
# "Request timed out after 30.0s"

raise RateLimitError(retry_after=60)
# "Rate limit exceeded. Retry after 60s"

# ❌ Bad: No context
raise TimeoutError()
# "Request timed out"  # After how long?
```

### User-Friendly Language

**Write for users, not developers**:

```python
# ✅ Good: User-friendly
raise InvalidConfigError(
    field="dimension",
    value=0,
    reason="must be a positive integer"
)
# "Invalid dimension=0: must be a positive integer"

# ❌ Bad: Technical jargon
raise ConfigurationError("dim validation failed: assert dim > 0")
```

## Error Metadata

All `APIError` exceptions include structured metadata for debugging and support:

### Accessing Metadata

```python
try:
    client.create_index(name="my-index", dimension=1536)
except APIError as e:
    # Always available
    print(f"Error: {e.message}")
    print(f"Status code: {e.status_code}")
    
    # Available when provided by server
    if e.request_id:
        print(f"Request ID for support: {e.request_id}")
    
    if e.error_code:
        print(f"Error code: {e.error_code}")
    
    if e.url:
        print(f"Failed URL: {e.url}")
    
    if e.method:
        print(f"HTTP method: {e.method}")
    
    if e.response_body:
        print(f"Response body: {e.response_body}")
```

### Using Request ID for Support

**Share request_id when contacting support**:

```python
try:
    response = client.inference.embed(
        model="multilingual-e5-large",
        texts=["Hello world"]
    )
except APIError as e:
    logger.error(
        f"API request failed: {e.message}",
        extra={
            "request_id": e.request_id,
            "status_code": e.status_code,
            "error_code": e.error_code,
        }
    )
    # When reporting to support, include the request_id
    if e.request_id:
        print(f"Please reference request ID {e.request_id} when contacting support")
```

### Programmatic Error Handling

**Use error_code for specific handling**:

```python
try:
    client.create_index(name="my-index", dimension=1536)
except BadRequestError as e:
    # Handle specific error codes differently
    if e.error_code == "INVALID_DIMENSION":
        print("Dimension must be between 1 and 20000")
    elif e.error_code == "NAME_ALREADY_EXISTS":
        print("Index with this name already exists")
    else:
        print(f"Bad request: {e.message}")
```

### Debugging with __repr__

**Use repr() for detailed debugging**:

```python
try:
    client.create_index(name="my-index", dimension=1536)
except APIError as e:
    # repr() includes all metadata
    logger.debug(f"Full error details: {e!r}")
    # Output: BadRequestError(message='Invalid dimension', status_code=400,
    #         request_id='req_abc123', error_code='INVALID_DIMENSION',
    #         url='https://api.pinecone.io/indexes', method='POST')
```

## Error Handling

### Catching Specific Errors

**Catch specific exceptions**:

```python
# ✅ Good: Handle specific errors differently
try:
    client.create_index(name="my-index", dimension=1536)
except InvalidAPIKeyError:
    print("Please set your API key")
    sys.exit(1)
except RateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after}s")
    time.sleep(e.retry_after)
except PineconeError as e:
    print(f"Error: {e.message}")
    sys.exit(1)

# ❌ Bad: Catch all exceptions
try:
    client.create_index(name="my-index", dimension=1536)
except Exception as e:  # Too broad
    print(f"Error: {e}")
```

### Re-raising with Context

**Add context when re-raising**:

```python
# ✅ Good: Add context, preserve original
try:
    response = httpx.get(url)
except httpx.TimeoutError as e:
    raise TimeoutError(timeout=30.0) from e

# ❌ Bad: Lose original exception
try:
    response = httpx.get(url)
except httpx.TimeoutError:
    raise TimeoutError(timeout=30.0)  # Lost original traceback
```

### Error Context Managers

**Clean up resources on errors**:

```python
# ✅ Good: Use context manager
class HTTPClient:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False  # Don't suppress exceptions

# Usage
with HTTPClient() as client:
    client.request("GET", "/indexes")  # Cleaned up even on error

# ❌ Bad: Manual cleanup
client = HTTPClient()
try:
    client.request("GET", "/indexes")
finally:
    client.close()  # Easy to forget
```

## Error Recovery

### Automatic Retries

**Retry transient errors automatically**:

```python
# ✅ Good: Retry rate limits and server errors
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}

def request_with_retry(
    method: str,
    url: str,
    max_retries: int = 3
) -> httpx.Response:
    for attempt in range(max_retries):
        try:
            response = httpx.request(method, url)
            if response.status_code in RETRYABLE_STATUS_CODES:
                wait = 2 ** attempt  # Exponential backoff
                time.sleep(wait)
                continue
            return response
        except httpx.TimeoutError:
            if attempt == max_retries - 1:
                raise TimeoutError(timeout=30.0)
            time.sleep(2 ** attempt)

    raise ServerError("Max retries exceeded")

# ❌ Bad: No retry for transient errors
def request(method: str, url: str) -> httpx.Response:
    return httpx.request(method, url)  # Fails on transient errors
```

### Graceful Degradation

**Provide fallbacks when possible**:

```python
# ✅ Good: Fallback to default
def get_environment() -> str:
    try:
        response = httpx.get("/environment")
        return response.json()["environment"]
    except (APIConnectionError, APIError):
        logger.warning("Failed to detect environment, using default")
        return "us-east-1"

# ❌ Bad: No fallback
def get_environment() -> str:
    response = httpx.get("/environment")
    return response.json()["environment"]  # Crashes on error
```

## Logging Errors

### Log Before Raising

**Log with context before raising**:

```python
import logging

logger = logging.getLogger(__name__)

# ✅ Good: Log with context
def create_index(name: str) -> Index:
    try:
        response = self._http.post("/indexes", json={"name": name})
        return Index.from_response(response)
    except httpx.HTTPError as e:
        logger.error(
            "Failed to create index",
            extra={"index_name": name, "error": str(e)}
        )
        raise APIConnectionError(f"Failed to create index '{name}'") from e

# ❌ Bad: No logging
def create_index(name: str) -> Index:
    response = self._http.post("/indexes", json={"name": name})
    return Index.from_response(response)
```

### Structured Logging

**Use structured logs with extra fields**:

```python
# ✅ Good: Structured logging
logger.error(
    "Request failed",
    extra={
        "method": "POST",
        "url": "/indexes",
        "status_code": response.status_code,
        "attempt": attempt,
    }
)

# ❌ Bad: String formatting only
logger.error(f"POST /indexes failed with status {response.status_code}")
```

## HTTP Error Mapping

### Status Code to Exception

**Map HTTP status codes to specific exceptions**:

```python
# ✅ Good: Specific exception per status code
def raise_for_status(response: httpx.Response) -> None:
    if response.status_code == 400:
        raise BadRequestError(
            response.json().get("message", "Bad request"),
            response.text
        )
    elif response.status_code == 401:
        raise UnauthorizedError()
    elif response.status_code == 404:
        raise NotFoundError(response.json().get("message", "Not found"))
    elif response.status_code == 429:
        retry_after = response.headers.get("Retry-After")
        raise RateLimitError(int(retry_after) if retry_after else None)
    elif 500 <= response.status_code < 600:
        raise ServerError(
            f"Server error: {response.status_code}",
            response.status_code
        )

# ❌ Bad: Generic error for all status codes
def raise_for_status(response: httpx.Response) -> None:
    if not response.is_success:
        raise APIError(f"Request failed: {response.status_code}")
```

## Validation Errors

### No Validation in Models

**Principle**: Trust API responses, don't validate

```python
# ✅ Good: Simple struct, no validation
class Vector(Struct):
    id: str
    values: list[float]
    metadata: dict[str, Any] | None = None

# ❌ Bad: Validating in model
class Vector(Struct):
    id: str
    values: list[float]
    metadata: dict[str, Any] | None = None

    def __post_init__(self):
        if not self.id:
            raise ValueError("id is required")  # Don't do this
        if not self.values:
            raise ValueError("values is required")
```

### Validate User Input Only

**Validate only at API boundaries**:

```python
# ✅ Good: Validate user input before sending request
def create_index(name: str, dimension: int) -> Index:
    if not name:
        raise InvalidConfigError("name", name, "cannot be empty")
    if dimension <= 0:
        raise InvalidConfigError("dimension", dimension, "must be positive")

    # Send to API (trust response)
    response = self._http.post("/indexes", json={"name": name, "dimension": dimension})
    return Index.from_response(response)

# ❌ Bad: Validating response from API
def create_index(name: str, dimension: int) -> Index:
    response = self._http.post("/indexes", json={"name": name, "dimension": dimension})
    index = Index.from_response(response)

    if not index.host:  # Don't validate API response
        raise ValueError("Invalid response: missing host")

    return index
```

## Anti-Patterns

### ❌ Generic Exceptions

```python
# Bad: Not specific
raise Exception("Something went wrong")
raise ValueError("Invalid input")
```

**Solution**: Use specific exception classes.

```python
# Good: Specific exceptions
raise InvalidAPIKeyError()
raise BadRequestError("Index name is required")
```

### ❌ Swallowing Errors

```python
# Bad: Silent failures
try:
    client.create_index(name)
except Exception:
    pass  # Error ignored

# Bad: Logging but not raising
try:
    client.create_index(name)
except Exception as e:
    logger.error(f"Error: {e}")
    return None  # Caller doesn't know about error
```

**Solution**: Let errors propagate or handle explicitly.

```python
# Good: Let error propagate
client.create_index(name)  # Let exception reach caller

# Good: Handle and raise
try:
    client.create_index(name)
except httpx.HTTPError as e:
    logger.error(f"Failed to create index: {e}")
    raise APIConnectionError("Failed to create index") from e
```

### ❌ Bare Except

```python
# Bad: Catching everything including SystemExit, KeyboardInterrupt
try:
    client.create_index(name)
except:  # Dangerous
    logger.error("Error")
```

**Solution**: Catch specific exceptions.

```python
# Good: Catch specific exceptions
try:
    client.create_index(name)
except PineconeError as e:
    logger.error(f"Pinecone error: {e}")
except httpx.HTTPError as e:
    logger.error(f"HTTP error: {e}")
```

### ❌ String Error Messages

```python
# Bad: Returning error strings
def create_index(name: str) -> Index | str:
    try:
        return self._create(name)
    except Exception as e:
        return f"Error: {e}"  # Anti-pattern
```

**Solution**: Raise exceptions, don't return error strings.

```python
# Good: Raise exceptions
def create_index(name: str) -> Index:
    return self._create(name)  # Let exceptions propagate
```

## Error Documentation

### Docstring Error Documentation

**Document exceptions in docstrings**:

```python
# ✅ Good: Documented exceptions
def create_index(name: str, dimension: int) -> Index:
    """
    Create a new index.

    Args:
        name: Name of the index
        dimension: Vector dimension

    Returns:
        The created index

    Raises:
        InvalidAPIKeyError: If API key is invalid
        BadRequestError: If name or dimension is invalid
        RateLimitError: If rate limit exceeded
        ServerError: If server error occurs
    """
    ...

# ❌ Bad: No error documentation
def create_index(name: str, dimension: int) -> Index:
    """Create a new index."""
    ...
```

## References

- See [naming.md](./naming.md) for exception naming conventions
- See [types.md](./types.md) for exception type hierarchies
- See [http-client.md](./http-client.md) for HTTP error handling patterns
- See [testing.md](./testing.md) for testing error conditions
