fix: preserve ProcessError context from failed CLI subprocesses#843
fix: preserve ProcessError context from failed CLI subprocesses#843lawrence3699 wants to merge 1 commit intoanthropics:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Preserves CLI subprocess failure context by keeping ProcessError intact through the query message pipeline and capturing a bounded tail of CLI stderr so callers can inspect real failure output.
Changes:
- Buffer and attach captured CLI stderr tail to
ProcessErroron non-zero subprocess exit. - Preserve and re-raise the original exception (e.g.,
ProcessError) fromQuery.receive_messages()instead of stringifying and raising a genericException. - Add tests covering stderr buffering/forwarding and exception preservation.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/claude_agent_sdk/_internal/transport/subprocess_cli.py |
Always pipes stderr, buffers its tail, forwards to sink, and includes captured stderr in ProcessError. |
src/claude_agent_sdk/_internal/query.py |
Sends the original exception object through the internal message stream and re-raises it in receive_messages(). |
tests/test_subprocess_buffering.py |
Adds coverage for captured stderr inclusion, forwarding to sink, and waiting for background stderr reader before raising. |
tests/test_query.py |
Adds a regression test ensuring ProcessError is preserved and surfaced to callers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Pipe stderr so ProcessError can include the CLI's actual stderr. | ||
| # We only stream it live when callers asked for callback/debug output. | ||
| should_pipe_stderr = ( | ||
| self._options.stderr is not None | ||
| or "debug-to-stderr" in self._options.extra_args | ||
| ) |
There was a problem hiding this comment.
The comment says stderr is only streamed live when callers request callback/debug output, but with stderr=PIPE and the subsequent or self._supports_live_stderr_reading() condition, stderr will typically be read in the background even when should_pipe_stderr is false. Consider rewording/removing this comment (or adjusting the condition) so it matches the actual behavior.
| await self._message_send.send( | ||
| {"type": "error", "error": str(e), "exception": e} | ||
| ) |
There was a problem hiding this comment.
Because the error message is still logged via logger.error(... {e}) (just above), adding the raw exception object here will commonly mean the log line now includes captured CLI stderr (since ProcessError embeds stderr in __str__). This may leak sensitive subprocess output into logs and duplicates stderr already forwarded to the sink. Consider redacting what gets logged (e.g., exception type + exit_code) or switching to logger.error("Fatal error in message reader", exc_info=True) without interpolating the message.
| # Preserve inherited-stderr behavior by forwarding stderr to | ||
| # the configured sink (defaults to sys.stderr). | ||
| elif self._options.debug_stderr: | ||
| self._options.debug_stderr.write(line_str + "\n") | ||
| if hasattr(self._options.debug_stderr, "flush"): | ||
| self._options.debug_stderr.flush() |
There was a problem hiding this comment.
This changes the meaning of debug_stderr: previously stderr was only forwarded to debug_stderr when "debug-to-stderr" was set in extra_args (and no stderr callback was provided). Now all CLI stderr is forwarded to debug_stderr whenever it’s non-null, which can be a backward-incompatible behavior change for callers who set debug_stderr to capture debug-only output. Consider restoring the old gating (or introducing a separate explicit stderr sink option) and updating the surrounding comment accordingly.
Summary
Fixes #800. Related to #798.
When the Claude CLI subprocess exits non-zero, the Python SDK currently loses most of the failure context in two places:
Querystringifies the original exception and re-raises a genericExceptionSubprocessCLITransportreplaces the CLI stderr with the placeholder"Check stderr output for details"This change preserves the original
ProcessErrorthrough the query message pipeline and captures the stderr tail so callers can inspect the real CLI failure.Before
query()surfaced a genericExceptioninstead of the originalProcessErrorProcessError.exit_code/ProcessError.stderrwere not available to SDK consumersAfter
query()re-raises the originalProcessErrorwhen the CLI subprocess failsexit_codeandstderrremain available on the exception objectProcessError.stderrwhile still forwarding stderr to the configured sinkTests
/tmp/fix-claude-agent-sdk-834/.venv/bin/python -m pytest tests/test_query.py tests/test_subprocess_buffering.py tests/test_transport.py -q/tmp/fix-claude-agent-sdk-834/.venv/bin/python -m ruff check src/ tests//tmp/fix-claude-agent-sdk-834/.venv/bin/python -m mypy src/