-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix: handle rate_limit_event in claude_agent_sdk to prevent parse errors #1875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Rex-Arnab
wants to merge
7
commits into
AndyMik90:develop
Choose a base branch
from
Rex-Arnab:fix/1864-rate-limit-event
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c40a4e0
fix: handle rate_limit_event in claude_agent_sdk to prevent parse errors
Rex-Arnab d39cde5
fix: patch claude_agent_sdk to handle rate_limit_event without errors
Rex-Arnab 267b74d
style(session): reformat rate limit event handling for better readabi…
Rex-Arnab 19cee12
fix(sdk): patch claude_agent_sdk to handle rate_limit_event safely
Rex-Arnab 4393006
test(sdk_patches): add comprehensive tests for apply_claude_agent_sdk…
Rex-Arnab 4ebf0ed
Merge branch 'develop' into fix/1864-rate-limit-event
Rex-Arnab 1e81af0
fix: add pragma no cover to apply_claude_agent_sdk_patches imports
Rex-Arnab File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """Runtime patches for third-party SDK bugs.""" | ||
|
|
||
| import logging | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def apply_claude_agent_sdk_patches() -> None: | ||
| """Patch claude_agent_sdk to handle rate_limit_event gracefully. | ||
|
|
||
| The bundled SDK raises MessageParseError for unknown message types including | ||
| rate_limit_event. This patch returns a SystemMessage instead of raising, | ||
| allowing the message loop in session.py to handle it and keep the stream open | ||
| while Claude Code waits for the rate limit to reset. | ||
|
|
||
| Patches both: | ||
| - message_parser.parse_message (the module attribute) | ||
| - _internal.client.parse_message (the already-bound module-level name that | ||
| the client's receive_response() generator actually calls via | ||
| `from .message_parser import parse_message` at import time) | ||
|
|
||
| Idempotent — safe to call multiple times from different entry points. | ||
| """ | ||
| try: | ||
| from claude_agent_sdk._internal import client as _ic | ||
| from claude_agent_sdk._internal import message_parser as _mp | ||
| from claude_agent_sdk.types import SystemMessage as _SystemMessage | ||
|
|
||
| if getattr(_mp, "_rate_limit_patched", False): | ||
| logger.debug("claude_agent_sdk already patched — skipping") | ||
| return | ||
|
|
||
| _orig_parse = _mp.parse_message | ||
|
|
||
| def _patched_parse(data: dict) -> object: | ||
| if isinstance(data, dict) and data.get("type") == "rate_limit_event": | ||
| logger.warning( | ||
| "Rate limit event received from Claude Code — " | ||
| "returning as SystemMessage so the stream stays open: %s", | ||
| data, | ||
| ) | ||
| return _SystemMessage(subtype="rate_limit_event", data=data) | ||
| return _orig_parse(data) | ||
|
|
||
| _patched_parse.__wrapped__ = _orig_parse # type: ignore[attr-defined] | ||
|
|
||
| _mp.parse_message = _patched_parse | ||
| _ic.parse_message = _patched_parse | ||
| _mp._rate_limit_patched = True # type: ignore[attr-defined] | ||
| logger.debug("claude_agent_sdk patched to handle rate_limit_event") | ||
| except Exception: | ||
| logger.warning( | ||
| "Failed to apply claude_agent_sdk rate_limit_event patch — " | ||
| "rate limit events may cause unexpected session failures", | ||
| exc_info=True, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| Tests for core.sdk_patches | ||
| ============================ | ||
|
|
||
| Covers: | ||
| - apply_claude_agent_sdk_patches() happy path | ||
| - Idempotency guard (no double-wrapping) | ||
| - rate_limit_event returns SystemMessage | ||
| - Other unknown message types still raise | ||
| - Graceful failure when SDK internals are unavailable | ||
| """ | ||
|
|
||
| import sys | ||
|
|
||
| # Ensure backend is on the path | ||
| from pathlib import Path | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend")) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def _make_sdk_mocks(): | ||
| """Build a minimal fake claude_agent_sdk module tree.""" | ||
|
|
||
| class _FakeMessageParseError(Exception): | ||
| pass | ||
|
|
||
| class _FakeSystemMessage: | ||
| def __init__(self, subtype: str, data: dict): | ||
| self.subtype = subtype | ||
| self.data = data | ||
|
|
||
| def _real_parse(data): | ||
| msg_type = data.get("type") if isinstance(data, dict) else None | ||
| if msg_type == "rate_limit_event": | ||
| raise _FakeMessageParseError(f"Unknown message type: {msg_type}") | ||
| if msg_type == "assistant": | ||
| return MagicMock(type="assistant") | ||
| raise _FakeMessageParseError(f"Unknown message type: {msg_type}") | ||
|
|
||
| # Build fake module objects | ||
| mp_mod = MagicMock() | ||
| mp_mod.parse_message = _real_parse | ||
| del mp_mod._rate_limit_patched # ensure sentinel absent at start | ||
|
|
||
| ic_mod = MagicMock() | ||
| ic_mod.parse_message = _real_parse | ||
|
|
||
| types_mod = MagicMock() | ||
| types_mod.SystemMessage = _FakeSystemMessage | ||
|
|
||
| sdk_mod = MagicMock() | ||
|
|
||
| internal_mod = MagicMock() | ||
| internal_mod.message_parser = mp_mod | ||
| internal_mod.client = ic_mod | ||
|
|
||
| return sdk_mod, internal_mod, mp_mod, ic_mod, types_mod, _FakeMessageParseError | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Fixtures | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _clean_patch_sentinel(): | ||
| """Remove any leftover _rate_limit_patched sentinel between tests.""" | ||
| yield | ||
| # Remove sdk_patches from sys.modules so each test gets a fresh import | ||
| sys.modules.pop("core.sdk_patches", None) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Tests for apply_claude_agent_sdk_patches | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class TestApplyClaudeAgentSdkPatches: | ||
| def _build_modules(self): | ||
| sdk, internal, mp, ic, types, err = _make_sdk_mocks() | ||
| modules = { | ||
| "claude_agent_sdk": sdk, | ||
| "claude_agent_sdk._internal": internal, | ||
| "claude_agent_sdk._internal.message_parser": mp, | ||
| "claude_agent_sdk._internal.client": ic, | ||
| "claude_agent_sdk.types": types, | ||
| } | ||
| return modules, mp, ic, types, err | ||
|
|
||
| def test_patches_both_call_sites(self): | ||
| """parse_message is replaced on both _mp and _ic after patching.""" | ||
| modules, mp, ic, types, _ = self._build_modules() | ||
| original = mp.parse_message | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| assert mp.parse_message is not original | ||
| assert ic.parse_message is not original | ||
| assert mp.parse_message is ic.parse_message | ||
|
|
||
| def test_rate_limit_event_returns_system_message(self): | ||
| """rate_limit_event data is converted to a SystemMessage without raising.""" | ||
| modules, mp, ic, types, _ = self._build_modules() | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| result = ic.parse_message({"type": "rate_limit_event", "retry_after": 30}) | ||
| assert type(result).__name__ == "_FakeSystemMessage" | ||
| assert result.subtype == "rate_limit_event" | ||
|
|
||
| def test_other_unknown_types_still_raise(self): | ||
| """Non-rate-limit unknown message types still propagate MessageParseError.""" | ||
| modules, mp, ic, types, FakeErr = self._build_modules() | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| with pytest.raises(FakeErr, match="Unknown message type: totally_unknown"): | ||
| ic.parse_message({"type": "totally_unknown"}) | ||
|
|
||
| def test_valid_messages_pass_through(self): | ||
| """Known message types are delegated to the original parser unchanged.""" | ||
| modules, mp, ic, types, _ = self._build_modules() | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| result = ic.parse_message({"type": "assistant"}) | ||
| assert result is not None | ||
|
|
||
| def test_idempotent_second_call_skips_rewrap(self): | ||
| """Calling apply_claude_agent_sdk_patches twice does not double-wrap.""" | ||
| modules, mp, ic, types, _ = self._build_modules() | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
| patched_once = ic.parse_message | ||
| apply_claude_agent_sdk_patches() | ||
| patched_twice = ic.parse_message | ||
|
|
||
| assert patched_once is patched_twice | ||
|
|
||
| def test_sets_rate_limit_patched_sentinel(self): | ||
| """_rate_limit_patched sentinel is set on the message_parser module.""" | ||
| modules, mp, ic, types, _ = self._build_modules() | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| assert getattr(mp, "_rate_limit_patched", False) is True | ||
|
|
||
| def test_wrapped_attribute_preserves_original(self): | ||
| """__wrapped__ on the patched function points to the original parser.""" | ||
| modules, mp, ic, types, _ = self._build_modules() | ||
| original = mp.parse_message | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| assert mp.parse_message.__wrapped__ is original | ||
|
|
||
| def test_graceful_failure_when_sdk_unavailable(self, caplog): | ||
| """Patch fails silently with a warning when the SDK is not installed.""" | ||
| import logging | ||
|
|
||
| broken = {"claude_agent_sdk._internal": None} | ||
| with patch.dict(sys.modules, broken): | ||
| sys.modules.pop("core.sdk_patches", None) | ||
| with caplog.at_level(logging.WARNING, logger="core.sdk_patches"): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| assert any("Failed to apply" in r.message for r in caplog.records) | ||
|
|
||
| def test_non_dict_data_passes_to_original(self): | ||
| """Non-dict data skips the rate_limit check and goes to original parser.""" | ||
| modules, mp, ic, types, FakeErr = self._build_modules() | ||
|
|
||
| with patch.dict(sys.modules, modules): | ||
| from core.sdk_patches import apply_claude_agent_sdk_patches | ||
|
|
||
| apply_claude_agent_sdk_patches() | ||
|
|
||
| # Original parser raises for non-dict / unknown input | ||
| with pytest.raises(FakeErr): | ||
| ic.parse_message("not-a-dict") |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.