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

from .._errors import ClaudeSDKError
from ..types import (
PermissionMode,
PermissionResultAllow,
Comment on lines 15 to 21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Line 195 stores a bare Exception(response.get("error", "Unknown error")) in self.pending_control_results[request_id] when a control_response has subtype "error". This exception is later re-raised at line 396 via raise result in _send_control_request(), so callers catching except ClaudeSDKError: will miss it — the exact bug class this PR aims to fix. Since the import is already present, the fix is just Exception(ClaudeSDKError( on line 195.

Extended reasoning...

The missed conversion

This PR converts 7 raise Exception(...) sites to raise ClaudeSDKError(...), but misses an 8th site at line 195 inside _read_messages(). When a control_response message arrives with subtype == "error", the code stores a bare Exception object:

self.pending_control_results[request_id] = Exception(
    response.get("error", "Unknown error")
)

How the stored exception is re-raised

In _send_control_request() (around line 396), after the response event fires, the code retrieves the stored result and checks if it is an exception:

result = self.pending_control_results.pop(request_id)
self.pending_control_responses.pop(request_id, None)

if isinstance(result, Exception):
    raise result

Since the stored object is a bare Exception (not a ClaudeSDKError), this raise result throws a plain Exception. Any caller catching except ClaudeSDKError: will not catch it — the exception escapes up the stack.

Step-by-step proof

  1. The SDK sends a control request (e.g., initialize, mcp_status, interrupt, set_permission_mode, etc.) via _send_control_request().
  2. The CLI responds with a control_response message where subtype == "error".
  3. _read_messages() routes this to line 195, storing Exception("some error message") in self.pending_control_results[request_id].
  4. The event is set, unblocking _send_control_request().
  5. _send_control_request() pops the result, sees isinstance(result, Exception) is True, and executes raise result.
  6. A bare Exception propagates up. Callers using except ClaudeSDKError: (as described in the PR description’s production incident) do not catch it.
  7. The exception crashes the calling code.

Impact

This is arguably the most impactful missed conversion because it is the error path for all control request failures. Every public method that calls _send_control_request() is affected: initialize(), get_mcp_status(), interrupt(), set_permission_mode(), set_model(), rewind_files(), reconnect_mcp_server(), toggle_mcp_server(), and stop_task().

Fix

The ClaudeSDKError import is already present (line 18, added by this PR). The fix is a one-word change on line 195:

self.pending_control_results[request_id] = ClaudeSDKError(
    response.get("error", "Unknown error")
)

Expand Down Expand Up @@ -253,7 +254,7 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
original_input = permission_request["input"]
# Handle tool permission request
if not self.can_use_tool:
raise Exception("canUseTool callback is not provided")
raise ClaudeSDKError("canUseTool callback is not provided")

context = ToolPermissionContext(
signal=None, # TODO: Add abort signal support
Expand Down Expand Up @@ -297,7 +298,9 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
callback_id = hook_callback_request["callback_id"]
callback = self.hook_callbacks.get(callback_id)
if not callback:
raise Exception(f"No hook callback found for ID: {callback_id}")
raise ClaudeSDKError(
f"No hook callback found for ID: {callback_id}"
)

hook_output = await callback(
request_data.get("input"),
Expand All @@ -313,7 +316,9 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
mcp_message = request_data.get("message")

if not server_name or not mcp_message:
raise Exception("Missing server_name or message for MCP request")
raise ClaudeSDKError(
"Missing server_name or message for MCP request"
)

# Type narrowing - we've verified these are not None above
assert isinstance(server_name, str)
Expand All @@ -325,7 +330,7 @@ async def _handle_control_request(self, request: SDKControlRequest) -> None:
response_data = {"mcp_response": mcp_response}

else:
raise Exception(f"Unsupported control request subtype: {subtype}")
raise ClaudeSDKError(f"Unsupported control request subtype: {subtype}")

# Send success response
success_response: SDKControlResponse = {
Expand Down Expand Up @@ -360,7 +365,7 @@ async def _send_control_request(
timeout: Timeout in seconds to wait for response (default 60s)
"""
if not self.is_streaming_mode:
raise Exception("Control requests require streaming mode")
raise ClaudeSDKError("Control requests require streaming mode")

# Generate unique request ID
self._request_counter += 1
Expand Down Expand Up @@ -395,7 +400,9 @@ async def _send_control_request(
except TimeoutError as e:
self.pending_control_responses.pop(request_id, None)
self.pending_control_results.pop(request_id, None)
raise Exception(f"Control request timeout: {request.get('subtype')}") from e
raise ClaudeSDKError(
f"Control request timeout: {request.get('subtype')}"
) from e

async def _handle_sdk_mcp_request(
self, server_name: str, message: dict[str, Any]
Expand Down Expand Up @@ -695,7 +702,7 @@ 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"))
raise ClaudeSDKError(message.get("error", "Unknown error"))

yield message

Expand Down
Loading