test_issue_handler_error_handling.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. from unittest.mock import MagicMock, patch
  2. import pytest
  3. import requests
  4. from litellm.exceptions import RateLimitError
  5. from openhands.core.config import LLMConfig
  6. from openhands.events.action.message import MessageAction
  7. from openhands.llm.llm import LLM
  8. from openhands.resolver.github_issue import GithubIssue
  9. from openhands.resolver.issue_definitions import IssueHandler, PRHandler
  10. @pytest.fixture(autouse=True)
  11. def mock_logger(monkeypatch):
  12. # suppress logging of completion data to file
  13. mock_logger = MagicMock()
  14. monkeypatch.setattr('openhands.llm.debug_mixin.llm_prompt_logger', mock_logger)
  15. monkeypatch.setattr('openhands.llm.debug_mixin.llm_response_logger', mock_logger)
  16. return mock_logger
  17. @pytest.fixture
  18. def default_config():
  19. return LLMConfig(
  20. model='gpt-4o',
  21. api_key='test_key',
  22. num_retries=2,
  23. retry_min_wait=1,
  24. retry_max_wait=2,
  25. )
  26. def test_handle_nonexistent_issue_reference():
  27. llm_config = LLMConfig(model='test', api_key='test')
  28. handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)
  29. # Mock the requests.get to simulate a 404 error
  30. mock_response = MagicMock()
  31. mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
  32. '404 Client Error: Not Found'
  33. )
  34. with patch('requests.get', return_value=mock_response):
  35. # Call the method with a non-existent issue reference
  36. result = handler._PRHandler__get_context_from_external_issues_references(
  37. closing_issues=[],
  38. closing_issue_numbers=[],
  39. issue_body='This references #999999', # Non-existent issue
  40. review_comments=[],
  41. review_threads=[],
  42. thread_comments=None,
  43. )
  44. # The method should return an empty list since the referenced issue couldn't be fetched
  45. assert result == []
  46. def test_handle_rate_limit_error():
  47. llm_config = LLMConfig(model='test', api_key='test')
  48. handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)
  49. # Mock the requests.get to simulate a rate limit error
  50. mock_response = MagicMock()
  51. mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
  52. '403 Client Error: Rate Limit Exceeded'
  53. )
  54. with patch('requests.get', return_value=mock_response):
  55. # Call the method with an issue reference
  56. result = handler._PRHandler__get_context_from_external_issues_references(
  57. closing_issues=[],
  58. closing_issue_numbers=[],
  59. issue_body='This references #123',
  60. review_comments=[],
  61. review_threads=[],
  62. thread_comments=None,
  63. )
  64. # The method should return an empty list since the request was rate limited
  65. assert result == []
  66. def test_handle_network_error():
  67. llm_config = LLMConfig(model='test', api_key='test')
  68. handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)
  69. # Mock the requests.get to simulate a network error
  70. with patch(
  71. 'requests.get', side_effect=requests.exceptions.ConnectionError('Network Error')
  72. ):
  73. # Call the method with an issue reference
  74. result = handler._PRHandler__get_context_from_external_issues_references(
  75. closing_issues=[],
  76. closing_issue_numbers=[],
  77. issue_body='This references #123',
  78. review_comments=[],
  79. review_threads=[],
  80. thread_comments=None,
  81. )
  82. # The method should return an empty list since the network request failed
  83. assert result == []
  84. def test_successful_issue_reference():
  85. llm_config = LLMConfig(model='test', api_key='test')
  86. handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config)
  87. # Mock a successful response
  88. mock_response = MagicMock()
  89. mock_response.raise_for_status.return_value = None
  90. mock_response.json.return_value = {'body': 'This is the referenced issue body'}
  91. with patch('requests.get', return_value=mock_response):
  92. # Call the method with an issue reference
  93. result = handler._PRHandler__get_context_from_external_issues_references(
  94. closing_issues=[],
  95. closing_issue_numbers=[],
  96. issue_body='This references #123',
  97. review_comments=[],
  98. review_threads=[],
  99. thread_comments=None,
  100. )
  101. # The method should return a list with the referenced issue body
  102. assert result == ['This is the referenced issue body']
  103. class MockLLMResponse:
  104. """Mock LLM Response class to mimic the actual LLM response structure."""
  105. class Choice:
  106. class Message:
  107. def __init__(self, content):
  108. self.content = content
  109. def __init__(self, content):
  110. self.message = self.Message(content)
  111. def __init__(self, content):
  112. self.choices = [self.Choice(content)]
  113. class DotDict(dict):
  114. """
  115. A dictionary that supports dot notation access.
  116. """
  117. def __init__(self, *args, **kwargs):
  118. super().__init__(*args, **kwargs)
  119. for key, value in self.items():
  120. if isinstance(value, dict):
  121. self[key] = DotDict(value)
  122. elif isinstance(value, list):
  123. self[key] = [
  124. DotDict(item) if isinstance(item, dict) else item for item in value
  125. ]
  126. def __getattr__(self, key):
  127. if key in self:
  128. return self[key]
  129. else:
  130. raise AttributeError(
  131. f"'{self.__class__.__name__}' object has no attribute '{key}'"
  132. )
  133. def __setattr__(self, key, value):
  134. self[key] = value
  135. def __delattr__(self, key):
  136. if key in self:
  137. del self[key]
  138. else:
  139. raise AttributeError(
  140. f"'{self.__class__.__name__}' object has no attribute '{key}'"
  141. )
  142. @patch('openhands.llm.llm.litellm_completion')
  143. def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_config):
  144. """Test that the retry mechanism in guess_success respects wait time between retries."""
  145. with patch('time.sleep') as mock_sleep:
  146. # Simulate a rate limit error followed by a successful response
  147. mock_litellm_completion.side_effect = [
  148. RateLimitError(
  149. 'Rate limit exceeded', llm_provider='test_provider', model='test_model'
  150. ),
  151. DotDict(
  152. {
  153. 'choices': [
  154. {
  155. 'message': {
  156. 'content': '--- success\ntrue\n--- explanation\nRetry successful'
  157. }
  158. }
  159. ]
  160. }
  161. ),
  162. ]
  163. llm = LLM(config=default_config)
  164. handler = IssueHandler('test-owner', 'test-repo', 'test-token', default_config)
  165. handler.llm = llm
  166. # Mock issue and history
  167. issue = GithubIssue(
  168. owner='test-owner',
  169. repo='test-repo',
  170. number=1,
  171. title='Test Issue',
  172. body='This is a test issue.',
  173. thread_comments=['Please improve error handling'],
  174. )
  175. history = [MessageAction(content='Fixed error handling.')]
  176. # Call guess_success
  177. success, _, explanation = handler.guess_success(issue, history)
  178. # Assertions
  179. assert success is True
  180. assert explanation == 'Retry successful'
  181. assert mock_litellm_completion.call_count == 2 # Two attempts made
  182. mock_sleep.assert_called_once() # Sleep called once between retries
  183. # Validate wait time
  184. wait_time = mock_sleep.call_args[0][0]
  185. assert (
  186. default_config.retry_min_wait <= wait_time <= default_config.retry_max_wait
  187. ), f'Expected wait time between {default_config.retry_min_wait} and {default_config.retry_max_wait} seconds, but got {wait_time}'
  188. @patch('openhands.llm.llm.litellm_completion')
  189. def test_guess_success_exhausts_retries(mock_completion, default_config):
  190. """Test the retry mechanism in guess_success exhausts retries and raises an error."""
  191. # Simulate persistent rate limit errors by always raising RateLimitError
  192. mock_completion.side_effect = RateLimitError(
  193. 'Rate limit exceeded', llm_provider='test_provider', model='test_model'
  194. )
  195. # Initialize LLM and handler
  196. llm = LLM(config=default_config)
  197. handler = PRHandler('test-owner', 'test-repo', 'test-token', default_config)
  198. handler.llm = llm
  199. # Mock issue and history
  200. issue = GithubIssue(
  201. owner='test-owner',
  202. repo='test-repo',
  203. number=1,
  204. title='Test Issue',
  205. body='This is a test issue.',
  206. thread_comments=['Please improve error handling'],
  207. )
  208. history = [MessageAction(content='Fixed error handling.')]
  209. # Call guess_success and expect it to raise an error after retries
  210. with pytest.raises(RateLimitError):
  211. handler.guess_success(issue, history)
  212. # Assertions
  213. assert (
  214. mock_completion.call_count == default_config.num_retries
  215. ) # Initial call + retries