You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ProcessError exit_code/stderr stripped during error propagation; users see generic Exception with hard-coded "Check stderr output for details" placeholder #800
When the bundled CLI subprocess exits non-zero, the original ProcessError (with structured exit_code and stderr fields) is destroyed during propagation through the message reader and re-raised as a generic Exception carrying only a string. Users cannot catch ProcessError, cannot access exit_code, and cannot get actual subprocess stderr — the stderr field of the original error is itself a hard-coded literal "Check stderr output for details", never the real subprocess output.
This is a triple defect compounding across three files and makes diagnosing Command failed with exit code 1 failures effectively impossible without forking the SDK.
Verified reproduction
importasynciofromclaude_agent_sdkimportClaudeAgentOptions, ProcessError, queryasyncdefmain():
options=ClaudeAgentOptions(
model="claude-haiku-4-5-20241022", # not a current model aliasmax_turns=1,
permission_mode="dontAsk",
)
try:
asyncfor_msginquery(prompt="hi", options=options):
passexceptExceptionasexc:
print(f"caught type: {type(exc).__name__}")
print(f"is ProcessError: {isinstance(exc, ProcessError)}")
print(f"message: {exc}")
print(f"has .stderr: {hasattr(exc, 'stderr')}")
print(f"has .exit_code: {hasattr(exc, 'exit_code')}")
asyncio.run(main())
Actual output:
caught type: Exception
is ProcessError: False
message: Command failed with exit code 1 (exit code: 1)
Error output: Check stderr output for details
has .stderr: False
has .exit_code: False
Expected output (any one of these would be a valid fix):
caught type: ProcessError
is ProcessError: True
message: Command failed with exit code 1
has .stderr: True
exc.stderr: "Error: 'claude-haiku-4-5-20241022' is not a valid model alias.\n Did you mean 'claude-haiku-4-5-20251001'?"
has .exit_code: True
exc.exit_code: 1
ifreturncodeisnotNoneandreturncode!=0:
self._exit_error=ProcessError(
f"Command failed with exit code {returncode}",
exit_code=returncode,
stderr="Check stderr output for details", # ← hard-coded literal
)
raiseself._exit_error
The literal string is what user code sees as the stderr field on the resulting error. The actual stderr stream from self._process is never read into this argument. The SDK only pipes stderr at all when the user explicitly opts in via options.stderr=callback or extra_args["debug-to-stderr"] (lines 371–377). For the default case, stderr is inherited by the parent process and is irretrievable from the exception.
Defect 2 — Original exception class destroyed in message reader
exceptExceptionase:
logger.error(f"Fatal error in message reader: {e}")
# Signal all pending control requests so they fail fast instead of timing outforrequest_id, eventinlist(self.pending_control_responses.items()):
ifrequest_idnotinself.pending_control_results:
self.pending_control_results[request_id] =eevent.set()
# Put error in stream so iterators can handle itawaitself._message_send.send({"type": "error", "error": str(e)})
The original ProcessError (with its exit_code and stderr attributes) is reduced to str(e) and stuffed into a dict. All structured information is lost.
This raises a bare Exception, not even ClaudeSDKError. Users who want to write except ProcessError as e: handle_subprocess_failure(e) cannot, because by the time the error reaches them it's no longer a ProcessError and no longer has exit_code or stderr attributes.
Why this matters
Concurrent setups: when running multiple query() calls in parallel and one or more fail, the parent terminal interleaves stderr from N subprocesses. Users have no programmatic way to associate a stderr line with a specific failed call. Each exception carries no per-call diagnostic.
CI / non-interactive: in CI, parent stderr may be captured but not associated with the exception. Structured error handling is impossible.
Type checking: try: ... except ProcessError as e: is the documented pattern but doesn't actually work because the type is destroyed.
Proposed fix
Three small, backward-compatible changes:
Fix 1 — Capture stderr to an internal ring buffer in subprocess_cli.py
Even when the user hasn't set options.stderr=callback, pipe the subprocess's stderr through a small (e.g., 8 KB) ring buffer. On non-zero exit, include the captured tail in the ProcessError:
captured_stderr=self._stderr_buffer.read_tail() # last 8KBself._exit_error=ProcessError(
f"Command failed with exit code {returncode}",
exit_code=returncode,
stderr=captured_stderror"(no stderr captured)",
)
Fix 2 — Preserve the exception object through the message reader
In query.py:255, store the actual exception (not str(e)) in the message:
This preserves the original ProcessError (or any other ClaudeSDKError subclass) all the way to the user, with exit_code and stderr intact. isinstance(exc, ProcessError) works again.
Environment
claude-agent-sdk 0.1.x (current as of April 2026)
Python 3.12 on macOS 14, arm64
Bundled CLI 2.x
Happy to PR all three fixes if maintainers are open — they're localized and ~30 LOC total.
Summary
When the bundled CLI subprocess exits non-zero, the original
ProcessError(with structuredexit_codeandstderrfields) is destroyed during propagation through the message reader and re-raised as a genericExceptioncarrying only a string. Users cannot catchProcessError, cannot accessexit_code, and cannot get actual subprocess stderr — thestderrfield of the original error is itself a hard-coded literal"Check stderr output for details", never the real subprocess output.This is a triple defect compounding across three files and makes diagnosing
Command failed with exit code 1failures effectively impossible without forking the SDK.Verified reproduction
Actual output:
Expected output (any one of these would be a valid fix):
Defect 1 —
stderrnever capturedsrc/claude_agent_sdk/_internal/transport/subprocess_cli.pylines 612–618 (current main):The literal string is what user code sees as the
stderrfield on the resulting error. The actual stderr stream fromself._processis never read into this argument. The SDK only pipes stderr at all when the user explicitly opts in viaoptions.stderr=callbackorextra_args["debug-to-stderr"](lines 371–377). For the default case, stderr is inherited by the parent process and is irretrievable from the exception.Defect 2 — Original exception class destroyed in message reader
src/claude_agent_sdk/_internal/query.pylines 247–256:The original
ProcessError(with itsexit_codeandstderrattributes) is reduced tostr(e)and stuffed into a dict. All structured information is lost.Defect 3 — Re-raised as generic
Exceptionsrc/claude_agent_sdk/_internal/query.pylines 724–733:This raises a bare
Exception, not evenClaudeSDKError. Users who want to writeexcept ProcessError as e: handle_subprocess_failure(e)cannot, because by the time the error reaches them it's no longer aProcessErrorand no longer hasexit_codeorstderrattributes.Why this matters
query()calls in parallel and one or more fail, the parent terminal interleaves stderr from N subprocesses. Users have no programmatic way to associate a stderr line with a specific failed call. Each exception carries no per-call diagnostic.try: ... except ProcessError as e:is the documented pattern but doesn't actually work because the type is destroyed.Proposed fix
Three small, backward-compatible changes:
Fix 1 — Capture stderr to an internal ring buffer in
subprocess_cli.pyEven when the user hasn't set
options.stderr=callback, pipe the subprocess's stderr through a small (e.g., 8 KB) ring buffer. On non-zero exit, include the captured tail in theProcessError:Fix 2 — Preserve the exception object through the message reader
In
query.py:255, store the actual exception (notstr(e)) in the message:Fix 3 — Re-raise the original exception in
receive_messagesIn
query.py:731:This preserves the original
ProcessError(or any otherClaudeSDKErrorsubclass) all the way to the user, withexit_codeandstderrintact.isinstance(exc, ProcessError)works again.Environment
claude-agent-sdk0.1.x (current as of April 2026)Happy to PR all three fixes if maintainers are open — they're localized and ~30 LOC total.