Skip to content
Open
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: 10 additions & 3 deletions src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
ListToolsRequest,
)

from .._errors import ProcessError
from .._errors import ClaudeSDKError, ProcessError
from ..types import (
PermissionMode,
PermissionResultAllow,
Expand Down Expand Up @@ -342,15 +342,19 @@ async def _read_messages(self) -> None:
f"Claude Code returned an error result: "
f"{self._last_error_result_text}"
)
error_exc: Exception = ClaudeSDKError(error_text)
logger.debug(
"Replacing ProcessError (exit code %s) with result error text",
e.exit_code,
)
else:
error_text = str(e)
error_exc = e
logger.error(f"Fatal error in message reader: {e}")
# Put error in stream so iterators can handle it
await self._message_send.send({"type": "error", "error": error_text})
await self._message_send.send(
{"type": "error", "error": error_text, "exception": error_exc}
)
finally:
# Flush any remaining transcript mirror entries before closing so
# an early stdout EOF or transport error doesn't drop entries
Expand Down Expand Up @@ -849,7 +853,10 @@ async def receive_messages(self) -> AsyncIterator[dict[str, Any]]:
if message.get("type") == "end":
break
elif message.get("type") == "error":
raise Exception(message.get("error", "Unknown error"))
exc = message.get("exception")
if isinstance(exc, Exception):
raise exc
raise ClaudeSDKError(message.get("error", "Unknown error"))

yield message

Expand Down
14 changes: 13 additions & 1 deletion src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import shutil
import signal
from collections import deque
from collections.abc import AsyncIterable, AsyncIterator
from contextlib import suppress
from pathlib import Path
Expand All @@ -28,6 +29,7 @@
logger = logging.getLogger(__name__)

_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
_STDERR_TAIL_LIMIT = 8 * 1024 # Keep the tail of stderr for ProcessError diagnostics.
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"

# Track live CLI subprocesses so we can terminate them when the parent Python
Expand Down Expand Up @@ -77,6 +79,8 @@ def __init__(
else _DEFAULT_MAX_BUFFER_SIZE
)
self._write_lock: anyio.Lock = anyio.Lock()
self._stderr_tail: deque[str] = deque()
self._stderr_tail_size: int = 0

def _find_cli(self) -> str:
"""Find Claude Code CLI binary."""
Expand Down Expand Up @@ -526,6 +530,13 @@ async def _handle_stderr(self) -> None:
if not line_str:
continue

# Capture stderr tail for ProcessError diagnostics.
self._stderr_tail.append(line_str)
self._stderr_tail_size += len(line_str) + 1
while self._stderr_tail and self._stderr_tail_size > _STDERR_TAIL_LIMIT:
removed = self._stderr_tail.popleft()
self._stderr_tail_size -= len(removed) + 1

# Call the stderr callback if provided. Isolate per-line so a
# raise in the user's callback doesn't terminate the loop and
# silently drop every subsequent line for the rest of the
Expand Down Expand Up @@ -707,10 +718,11 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]:

# Use exit code for error detection
if returncode is not None and returncode != 0:
stderr_output = "\n".join(self._stderr_tail) if self._stderr_tail else None
self._exit_error = ProcessError(
f"Command failed with exit code {returncode}",
exit_code=returncode,
stderr="Check stderr output for details",
stderr=stderr_output,
)
raise self._exit_error

Expand Down