Skip to content

Commit 9e51e2f

Browse files
Python: fix(claude): handle API errors in run_stream() method (microsoft#3653)
* fix(claude): handle API errors in run_stream() method - Import AssistantMessage and TextBlock from claude_agent_sdk - Check AssistantMessage.error and raise ServiceException with descriptive message - Check ResultMessage.is_error and raise ServiceException with error details - Add tests for error handling in run_stream() Fixes microsoft#3652 * fix: add defensive check for message.content before iterating Address PR review feedback - add null check for message.content to prevent potential AttributeError if content is None. * chore: refresh uv.lock * chore: fix import sorting * chore: refresh uv.lock
1 parent 0daa770 commit 9e51e2f

3 files changed

Lines changed: 100 additions & 18 deletions

File tree

python/packages/claude/agent_framework_claude/_agent.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@
2323
from agent_framework._types import normalize_tools
2424
from agent_framework.exceptions import ServiceException, ServiceInitializationError
2525
from claude_agent_sdk import (
26-
ClaudeAgentOptions as SDKOptions,
27-
)
28-
from claude_agent_sdk import (
26+
AssistantMessage,
2927
ClaudeSDKClient,
3028
ResultMessage,
3129
SdkMcpTool,
3230
create_sdk_mcp_server,
3331
)
34-
from claude_agent_sdk.types import StreamEvent
32+
from claude_agent_sdk import (
33+
ClaudeAgentOptions as SDKOptions,
34+
)
35+
from claude_agent_sdk.types import StreamEvent, TextBlock
3536
from pydantic import ValidationError
3637

3738
from ._settings import ClaudeAgentSettings
@@ -639,7 +640,33 @@ async def run_stream(
639640
contents=[Content.from_text_reasoning(text=thinking, raw_representation=message)],
640641
raw_representation=message,
641642
)
643+
elif isinstance(message, AssistantMessage):
644+
# Handle AssistantMessage - check for API errors
645+
# Note: In streaming mode, the content was already yielded via StreamEvent,
646+
# so we only check for errors here, not re-emit content.
647+
if message.error:
648+
# Map error types to descriptive messages
649+
error_messages = {
650+
"authentication_failed": "Authentication failed with Claude API",
651+
"billing_error": "Billing error with Claude API",
652+
"rate_limit": "Rate limit exceeded for Claude API",
653+
"invalid_request": "Invalid request to Claude API",
654+
"server_error": "Claude API server error",
655+
"unknown": "Unknown error from Claude API",
656+
}
657+
error_msg = error_messages.get(message.error, f"Claude API error: {message.error}")
658+
# Extract any error details from content blocks
659+
if message.content:
660+
for block in message.content:
661+
if isinstance(block, TextBlock):
662+
error_msg = f"{error_msg}: {block.text}"
663+
break
664+
raise ServiceException(error_msg)
642665
elif isinstance(message, ResultMessage):
666+
# Check for errors in result message
667+
if message.is_error:
668+
error_msg = message.result or "Unknown error from Claude API"
669+
raise ServiceException(f"Claude API error: {error_msg}")
643670
session_id = message.session_id
644671

645672
# Update thread with session ID

python/packages/claude/tests/test_claude_agent.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,61 @@ async def test_run_stream_yields_updates(self) -> None:
379379
assert updates[0].text == "Streaming "
380380
assert updates[1].text == "response"
381381

382+
async def test_run_stream_raises_on_assistant_message_error(self) -> None:
383+
"""Test run_stream raises ServiceException when AssistantMessage has an error."""
384+
from agent_framework.exceptions import ServiceException
385+
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
386+
387+
messages = [
388+
AssistantMessage(
389+
content=[TextBlock(text="Error details from API")],
390+
model="claude-sonnet",
391+
error="invalid_request",
392+
),
393+
ResultMessage(
394+
subtype="success",
395+
duration_ms=100,
396+
duration_api_ms=50,
397+
is_error=False,
398+
num_turns=1,
399+
session_id="error-session",
400+
),
401+
]
402+
mock_client = self._create_mock_client(messages)
403+
404+
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
405+
agent = ClaudeAgent()
406+
with pytest.raises(ServiceException) as exc_info:
407+
async for _ in agent.run_stream("Hello"):
408+
pass
409+
assert "Invalid request to Claude API" in str(exc_info.value)
410+
assert "Error details from API" in str(exc_info.value)
411+
412+
async def test_run_stream_raises_on_result_message_error(self) -> None:
413+
"""Test run_stream raises ServiceException when ResultMessage.is_error is True."""
414+
from agent_framework.exceptions import ServiceException
415+
from claude_agent_sdk import ResultMessage
416+
417+
messages = [
418+
ResultMessage(
419+
subtype="error",
420+
duration_ms=100,
421+
duration_api_ms=50,
422+
is_error=True,
423+
num_turns=0,
424+
session_id="error-session",
425+
result="Model 'claude-sonnet-4.5' not found",
426+
),
427+
]
428+
mock_client = self._create_mock_client(messages)
429+
430+
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
431+
agent = ClaudeAgent()
432+
with pytest.raises(ServiceException) as exc_info:
433+
async for _ in agent.run_stream("Hello"):
434+
pass
435+
assert "Model 'claude-sonnet-4.5' not found" in str(exc_info.value)
436+
382437

383438
# region Test ClaudeAgent Session Management
384439

python/uv.lock

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)