From 68fe1c33da15c0ba2bd7426b3be5ea1e53233310 Mon Sep 17 00:00:00 2001 From: itxaiohanglover <1531137510@qq.com> Date: Fri, 15 May 2026 10:54:56 +0800 Subject: [PATCH] fix: preserve error context in CLI failure exceptions When the Claude CLI subprocess exits non-zero, the SDK was destroying error context through exception flattening: 1. ProcessError used a hardcoded stderr placeholder instead of actual CLI stderr output 2. query.py stringified exceptions via str(e), losing type/attributes 3. receive_messages() re-raised as bare Exception, so consumer except ClaudeSDKError handlers never fired Fix by: - Capturing stderr tail in SubprocessCLITransport and using it in ProcessError instead of the placeholder - Passing the original exception object through the message stream - Re-raising the preserved exception (or ClaudeSDKError as fallback) in receive_messages() so typed exception handlers work correctly Closes #798 --- src/claude_agent_sdk/_internal/query.py | 13 ++++++++++--- .../_internal/transport/subprocess_cli.py | 14 +++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 7a4f8a44..6ddfd6e8 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -15,7 +15,7 @@ ListToolsRequest, ) -from .._errors import ProcessError +from .._errors import ClaudeSDKError, ProcessError from ..types import ( PermissionMode, PermissionResultAllow, @@ -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 @@ -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 diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 833cba4c..49410872 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -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 @@ -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 @@ -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.""" @@ -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 @@ -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