Skip to content
Draft
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
13 changes: 13 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
from typing import Any, Generic, TypeVar

from ._errors import (
APIError,
AuthenticationError,
BillingError,
ClaudeSDKError,
CLIConnectionError,
CLIJSONDecodeError,
CLINotFoundError,
InvalidRequestError,
ProcessError,
RateLimitError,
ServerError,
)
from ._internal.transport import Transport
from ._version import __version__
Expand Down Expand Up @@ -362,4 +368,11 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"CLINotFoundError",
"ProcessError",
"CLIJSONDecodeError",
# API Errors
"APIError",
"AuthenticationError",
"BillingError",
"RateLimitError",
"InvalidRequestError",
"ServerError",
]
182 changes: 181 additions & 1 deletion src/claude_agent_sdk/_errors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Error types for Claude SDK."""

from typing import Any
from typing import Any, Literal


class ClaudeSDKError(Exception):
Expand Down Expand Up @@ -54,3 +54,183 @@ class MessageParseError(ClaudeSDKError):
def __init__(self, message: str, data: dict[str, Any] | None = None):
self.data = data
super().__init__(message)


# API Error types
# These correspond to AssistantMessageError in types.py:
# "authentication_failed", "billing_error", "rate_limit",
# "invalid_request", "server_error", "unknown"

APIErrorType = Literal[
"authentication_failed",
"billing_error",
"rate_limit",
"invalid_request",
"server_error",
"unknown",
]


class APIError(ClaudeSDKError):
"""Base exception for API errors returned by the Anthropic API.

This exception is raised when the Claude CLI returns an assistant message
with an error field, indicating an API-level failure such as authentication
errors, rate limits, or invalid requests.

Catching this exception allows programmatic handling of API errors instead
of having them appear as text messages in the response stream.

Attributes:
error_type: The type of API error (e.g., "rate_limit", "authentication_failed")
message: Human-readable error message extracted from the response
model: The model that was being used when the error occurred
"""

def __init__(
self,
message: str,
error_type: APIErrorType = "unknown",
model: str | None = None,
):
self.error_type = error_type
self.model = model
super().__init__(message)


class AuthenticationError(APIError):
"""Raised when API authentication fails (invalid or expired API key).

This typically indicates:
- Invalid API key
- Expired API key
- API key without required permissions

Example:
try:
async for msg in query("Hello"):
print(msg)
except AuthenticationError as e:
print(f"Auth failed: {e}")
# Prompt user to check API key
"""

def __init__(self, message: str = "Authentication failed", model: str | None = None):
super().__init__(message, error_type="authentication_failed", model=model)


class BillingError(APIError):
"""Raised when there's a billing issue with the API account.

This typically indicates:
- Insufficient credits
- Payment method issues
- Account suspension due to billing

Example:
try:
async for msg in query("Hello"):
print(msg)
except BillingError as e:
print(f"Billing issue: {e}")
# Prompt user to check account balance
"""

def __init__(
self, message: str = "Billing error", model: str | None = None
):
super().__init__(message, error_type="billing_error", model=model)


class RateLimitError(APIError):
"""Raised when API rate limits are exceeded.

This typically indicates:
- Too many requests per minute
- Token usage limits exceeded
- Concurrent request limits exceeded

Applications should implement retry logic with exponential backoff
when catching this exception.

Example:
import asyncio

async def query_with_retry(prompt, max_retries=3):
for attempt in range(max_retries):
try:
async for msg in query(prompt):
yield msg
return
except RateLimitError:
if attempt < max_retries - 1:
await asyncio.sleep(2 ** attempt)
else:
raise
"""

def __init__(self, message: str = "Rate limit exceeded", model: str | None = None):
super().__init__(message, error_type="rate_limit", model=model)


class InvalidRequestError(APIError):
"""Raised when the API request is invalid.

This typically indicates:
- Invalid model identifier
- Malformed request parameters
- Input exceeds model limits
- Invalid tool configurations

Example:
try:
async for msg in query("Hello", options=ClaudeAgentOptions(model="invalid")):
print(msg)
except InvalidRequestError as e:
print(f"Invalid request: {e}")
"""

def __init__(self, message: str = "Invalid request", model: str | None = None):
super().__init__(message, error_type="invalid_request", model=model)


class ServerError(APIError):
"""Raised when the API server encounters an internal error.

This typically indicates:
- Server-side issues (5xx errors)
- API overload (529)
- Temporary service disruption

Applications should implement retry logic when catching this exception,
as server errors are often transient.

Example:
try:
async for msg in query("Hello"):
print(msg)
except ServerError as e:
print(f"Server error (retrying...): {e}")
"""

def __init__(self, message: str = "Server error", model: str | None = None):
super().__init__(message, error_type="server_error", model=model)


def get_api_error_class(error_type: str) -> type[APIError]:
"""Get the appropriate APIError subclass for an error type.

Args:
error_type: The error type string from AssistantMessage.error

Returns:
The appropriate APIError subclass, or APIError for unknown types
"""
error_map: dict[str, type[APIError]] = {
"authentication_failed": AuthenticationError,
"billing_error": BillingError,
"rate_limit": RateLimitError,
"invalid_request": InvalidRequestError,
"server_error": ServerError,
}
return error_map.get(error_type, APIError)
106 changes: 102 additions & 4 deletions src/claude_agent_sdk/_internal/message_parser.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Message parser for Claude Code SDK responses."""

import json
import logging
from typing import Any

from .._errors import MessageParseError
from .._errors import MessageParseError, get_api_error_class
from ..types import (
AssistantMessage,
ContentBlock,
Expand All @@ -20,6 +21,79 @@

logger = logging.getLogger(__name__)

# Common wrapper keys that models may add around structured output
# See: https://github.com/anthropics/claude-agent-sdk-python/issues/502
_WRAPPER_KEYS = frozenset({"output", "response", "json", "data", "result"})


def _normalize_structured_output(value: Any) -> Any:
"""Normalize structured output by unwrapping common wrapper keys and parsing stringified JSON.

This handles two common issues with model-generated structured output:

1. Wrapper keys (#502): Model wraps data in {"output": {...}}, {"response": {...}}, etc.
We unwrap these to return just the inner data.

2. Stringified JSON (#510): Model serializes arrays/objects as JSON strings like
"[{\\"field\\": ...}]" instead of native arrays. We parse these back to native types.

Args:
value: The raw structured_output value from the CLI

Returns:
Normalized structured output with wrappers removed and strings parsed
"""
if value is None:
return None

# Handle wrapper keys: {"output": {...}} -> {...}
if isinstance(value, dict) and len(value) == 1:
key = next(iter(value.keys()))
if key.lower() in _WRAPPER_KEYS:
logger.debug(f"Unwrapping structured_output from '{key}' wrapper")
value = value[key]

# Recursively normalize the value (handles nested stringified JSON)
return _parse_stringified_json(value)


def _parse_stringified_json(value: Any) -> Any:
"""Recursively parse stringified JSON values back to native Python types.

Handles cases where the model serializes arrays/objects as strings:
- "[{\\"field\\": \\"value\\"}]" -> [{"field": "value"}]
- "{\\"key\\": \\"value\\"}" -> {"key": "value"}

Args:
value: Any value that may contain stringified JSON

Returns:
Value with stringified JSON parsed to native types
"""
if isinstance(value, str):
# Try to parse strings that look like JSON arrays or objects
stripped = value.strip()
if (stripped.startswith("[") and stripped.endswith("]")) or (
stripped.startswith("{") and stripped.endswith("}")
):
try:
parsed = json.loads(value)
logger.debug("Parsed stringified JSON in structured_output")
# Recursively normalize the parsed value
return _parse_stringified_json(parsed)
except json.JSONDecodeError:
# Not valid JSON, return as-is
pass
return value

if isinstance(value, dict):
return {k: _parse_stringified_json(v) for k, v in value.items()}

if isinstance(value, list):
return [_parse_stringified_json(item) for item in value]

return value


def parse_message(data: dict[str, Any]) -> Message:
"""
Expand Down Expand Up @@ -122,11 +196,29 @@ def parse_message(data: dict[str, Any]) -> Message:
)
)

# Error field is at top level, not inside message object
# See: https://github.com/anthropics/claude-agent-sdk-python/issues/505
error_type = data.get("error")
model = data["message"]["model"]

# Raise exception for API errors instead of returning them as messages
# See: https://github.com/anthropics/claude-agent-sdk-python/issues/472
if error_type is not None:
# Extract error message from content if available
error_message = "API error"
for block in content_blocks:
if isinstance(block, TextBlock) and block.text:
error_message = block.text
break

error_class = get_api_error_class(error_type)
raise error_class(error_message, model=model)

return AssistantMessage(
content=content_blocks,
model=data["message"]["model"],
model=model,
parent_tool_use_id=data.get("parent_tool_use_id"),
error=data["message"].get("error"),
error=error_type,
)
except KeyError as e:
raise MessageParseError(
Expand All @@ -146,6 +238,12 @@ def parse_message(data: dict[str, Any]) -> Message:

case "result":
try:
# Normalize structured_output to handle wrapper keys and stringified JSON
# See: https://github.com/anthropics/claude-agent-sdk-python/issues/502
# See: https://github.com/anthropics/claude-agent-sdk-python/issues/510
raw_structured_output = data.get("structured_output")
normalized_output = _normalize_structured_output(raw_structured_output)

return ResultMessage(
subtype=data["subtype"],
duration_ms=data["duration_ms"],
Expand All @@ -156,7 +254,7 @@ def parse_message(data: dict[str, Any]) -> Message:
total_cost_usd=data.get("total_cost_usd"),
usage=data.get("usage"),
result=data.get("result"),
structured_output=data.get("structured_output"),
structured_output=normalized_output,
)
except KeyError as e:
raise MessageParseError(
Expand Down
Loading