-
Notifications
You must be signed in to change notification settings - Fork 12
Re-raise unrecognized exceptions on retry. #168
base: master
Are you sure you want to change the base?
Changes from all commits
1e78db7
547f9d9
145a00d
09b3342
767a960
1251b57
c725aa8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,158 +32,156 @@ | |
|
|
||
| from __future__ import absolute_import, division | ||
|
|
||
| import unittest | ||
|
|
||
| import mock | ||
| import unittest2 | ||
|
|
||
| from google.gax import BackoffSettings, errors, retry, RetryOptions | ||
| from google.gax import BackoffSettings | ||
| from google.gax import retry | ||
| from google.gax import RetryOptions | ||
|
|
||
| _MILLIS_PER_SEC = 1000 | ||
| from tests.utils import errors | ||
|
|
||
|
|
||
| _FAKE_STATUS_CODE_1 = object() | ||
|
|
||
| _MILLIS_PER_SEC = 1000 | ||
|
|
||
| _FAKE_STATUS_CODE_1 = object() | ||
| _FAKE_STATUS_CODE_2 = object() | ||
|
|
||
|
|
||
| class CustomException(Exception): | ||
| def __init__(self, msg, code): | ||
| super(CustomException, self).__init__(msg) | ||
| self.code = code | ||
|
|
||
|
|
||
| class TestRetry(unittest2.TestCase): | ||
|
|
||
| @mock.patch('google.gax.config.exc_to_code') | ||
| class TestRetry(unittest.TestCase): | ||
| @mock.patch('time.time') | ||
| def test_retryable_without_timeout(self, mock_time, mock_exc_to_code): | ||
| def test_retryable_without_timeout(self, mock_time): | ||
| mock_time.return_value = 0 | ||
| mock_exc_to_code.side_effect = lambda e: e.code | ||
|
|
||
| # Define the exception to be raised on every attempt before the | ||
| # last one, and the result for the last attempt. | ||
| to_attempt = 3 | ||
| mock_call = mock.Mock() | ||
| mock_call.side_effect = ([CustomException('', _FAKE_STATUS_CODE_1)] * | ||
| (to_attempt - 1) + [mock.DEFAULT]) | ||
| mock_call.return_value = 1729 | ||
| exc = errors.MockGrpcException(code=_FAKE_STATUS_CODE_1) | ||
| mock_func = mock.Mock() | ||
| mock_func.side_effect = [exc] * (to_attempt - 1) + [mock.DEFAULT] | ||
| mock_func.return_value = 1729 | ||
|
|
||
| retry_options = RetryOptions( | ||
| [_FAKE_STATUS_CODE_1], | ||
| BackoffSettings(0, 0, 0, None, None, None, None)) | ||
| BackoffSettings(0, 0, 0, None, None, None, None), | ||
| ) | ||
|
|
||
| my_callable = retry.retryable(mock_call, retry_options) | ||
| my_callable = retry.retryable(mock_func, retry_options) | ||
| result = my_callable(None) | ||
|
|
||
| self.assertEqual(my_callable(None), 1729) | ||
| self.assertEqual(to_attempt, mock_call.call_count) | ||
| self.assertEqual(result, 1729) | ||
| self.assertEqual(to_attempt, mock_func.call_count) | ||
|
|
||
| @mock.patch('google.gax.config.exc_to_code') | ||
| @mock.patch('time.time') | ||
| def test_retryable_with_timeout(self, mock_time, mock_exc_to_code): | ||
| def test_retryable_with_timeout(self, mock_time): | ||
| mock_time.return_value = 1 | ||
| mock_exc_to_code.side_effect = lambda e: e.code | ||
|
|
||
| mock_call = mock.Mock() | ||
| mock_call.side_effect = [CustomException('', _FAKE_STATUS_CODE_1), | ||
| mock.DEFAULT] | ||
| mock_call.return_value = 1729 | ||
| mock_func = mock.Mock() | ||
| mock_func.side_effect = [ | ||
| errors.MockGrpcException(code=_FAKE_STATUS_CODE_1), | ||
| mock.DEFAULT, | ||
| ] | ||
| mock_func.return_value = 1729 | ||
|
|
||
| retry_options = RetryOptions( | ||
| [_FAKE_STATUS_CODE_1], | ||
| BackoffSettings(0, 0, 0, 0, 0, 0, 0)) | ||
| BackoffSettings(0, 0, 0, 0, 0, 0, 0), | ||
| ) | ||
|
|
||
| my_callable = retry.retryable(mock_call, retry_options) | ||
| my_callable = retry.retryable(mock_func, retry_options) | ||
|
|
||
| self.assertRaises(errors.RetryError, my_callable) | ||
| self.assertEqual(0, mock_call.call_count) | ||
| self.assertEqual(0, mock_func.call_count) | ||
|
|
||
| @mock.patch('google.gax.config.exc_to_code') | ||
| @mock.patch('time.time') | ||
| def test_retryable_when_no_codes(self, mock_time, mock_exc_to_code): | ||
| def test_retryable_when_no_codes(self, mock_time): | ||
| mock_time.return_value = 0 | ||
| mock_exc_to_code.side_effect = lambda e: e.code | ||
|
|
||
| mock_call = mock.Mock() | ||
| mock_call.side_effect = [CustomException('', _FAKE_STATUS_CODE_1), | ||
| mock.DEFAULT] | ||
| mock_call.return_value = 1729 | ||
|
|
||
| # Set up the mock function to raise an exception that is *not* | ||
| # an expected code. | ||
| mock_func = mock.Mock() | ||
| mock_func.side_effect = [ | ||
| errors.MockGrpcException(code=_FAKE_STATUS_CODE_2), | ||
| mock.DEFAULT, | ||
| ] | ||
| mock_func.return_value = 1729 | ||
|
|
||
| # Set the retry options not to actually honor any codes | ||
| # (thus, our code is not in the list). | ||
| retry_options = RetryOptions( | ||
| [], | ||
| BackoffSettings(0, 0, 0, 0, 0, 0, 1)) | ||
| BackoffSettings(0, 0, 0, 0, 0, 0, 1), | ||
| ) | ||
|
|
||
| my_callable = retry.retryable(mock_call, retry_options) | ||
|
|
||
| try: | ||
| # Create the callable and establish that we get a GaxError. | ||
| my_callable = retry.retryable(mock_func, retry_options) | ||
| with self.assertRaises(errors.GaxError): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is testing weaker condition than before, since we're no longer checking the cause of the GaxError. |
||
| my_callable(None) | ||
| self.fail('Should not have been reached') | ||
| except errors.RetryError as exc: | ||
| self.assertIsInstance(exc.cause, CustomException) | ||
|
|
||
| self.assertEqual(1, mock_call.call_count) | ||
| # The actual retryable function should have been called exactly once. | ||
| mock_func.assert_called_once() | ||
|
|
||
| @mock.patch('google.gax.config.exc_to_code') | ||
| @mock.patch('time.time') | ||
| def test_retryable_aborts_on_unexpected_exception( | ||
| self, mock_time, mock_exc_to_code): | ||
| def test_retryable_aborts_on_unexpected_exception(self, mock_time): | ||
| mock_time.return_value = 0 | ||
| mock_exc_to_code.side_effect = lambda e: e.code | ||
|
|
||
| mock_call = mock.Mock() | ||
| mock_call.side_effect = [CustomException('', _FAKE_STATUS_CODE_2), | ||
| mock.DEFAULT] | ||
| mock_call.return_value = 1729 | ||
| # Set up the mock function to raise an exception that should be | ||
| # bubbled up (because it is not recognized). | ||
| mock_func = mock.Mock() | ||
| mock_func.side_effect = [ | ||
| ValueError('bogus'), | ||
| mock.DEFAULT, | ||
| ] | ||
| mock_func.return_value = 1729 | ||
|
|
||
| retry_options = RetryOptions( | ||
| [_FAKE_STATUS_CODE_1], | ||
| BackoffSettings(0, 0, 0, 0, 0, 0, 1)) | ||
|
|
||
| my_callable = retry.retryable(mock_call, retry_options) | ||
| BackoffSettings(0, 0, 0, 0, 0, 0, 1), | ||
| ) | ||
| my_callable = retry.retryable(mock_func, retry_options) | ||
|
|
||
| try: | ||
| # Establish that the custom exception is bubbled up (not wrapped), and | ||
| # that the retryable function was called only once, not twice. | ||
| with self.assertRaises(ValueError): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, this is testing a weaker condition, since it ignores the cause.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In this case, there is no
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, right. Ignore that comment. |
||
| my_callable(None) | ||
| self.fail('Should not have been reached') | ||
| except errors.RetryError as exc: | ||
| self.assertIsInstance(exc.cause, CustomException) | ||
|
|
||
| self.assertEqual(1, mock_call.call_count) | ||
| mock_func.assert_called_once() | ||
|
|
||
| @mock.patch('google.gax.config.exc_to_code') | ||
| @mock.patch('time.sleep') | ||
| @mock.patch('time.time') | ||
| def test_retryable_exponential_backoff( | ||
| self, mock_time, mock_sleep, mock_exc_to_code): | ||
| def test_retryable_exponential_backoff(self, mock_time, mock_sleep): | ||
| def incr_time(secs): | ||
| mock_time.return_value += secs | ||
|
|
||
| def api_call(timeout): | ||
| incr_time(timeout) | ||
| raise CustomException(str(timeout), _FAKE_STATUS_CODE_1) | ||
| raise errors.MockGrpcException(str(timeout), _FAKE_STATUS_CODE_1) | ||
|
|
||
| mock_time.return_value = 0 | ||
| mock_sleep.side_effect = incr_time | ||
| mock_exc_to_code.side_effect = lambda e: e.code | ||
|
|
||
| mock_call = mock.Mock() | ||
| mock_call.side_effect = api_call | ||
| mock_func = mock.Mock() | ||
| mock_func.side_effect = api_call | ||
|
|
||
| params = BackoffSettings(3, 2, 24, 5, 2, 80, 2500) | ||
| retry_options = RetryOptions([_FAKE_STATUS_CODE_1], params) | ||
|
|
||
| my_callable = retry.retryable(mock_call, retry_options) | ||
| my_callable = retry.retryable(mock_func, retry_options) | ||
|
|
||
| try: | ||
| my_callable() | ||
| self.fail('Should not have been reached') | ||
| except errors.RetryError as exc: | ||
| self.assertIsInstance(exc.cause, CustomException) | ||
| self.assertIsInstance(exc.cause, errors.MockGrpcException) | ||
|
|
||
| self.assertGreaterEqual(mock_time(), | ||
| params.total_timeout_millis / _MILLIS_PER_SEC) | ||
|
|
||
| # Very rough bounds | ||
| calls_lower_bound = params.total_timeout_millis / ( | ||
| params.max_retry_delay_millis + params.max_rpc_timeout_millis) | ||
| self.assertGreater(mock_call.call_count, calls_lower_bound) | ||
| self.assertGreater(mock_func.call_count, calls_lower_bound) | ||
|
|
||
| calls_upper_bound = (params.total_timeout_millis / | ||
| params.initial_retry_delay_millis) | ||
| self.assertLess(mock_call.call_count, calls_upper_bound) | ||
| self.assertLess(mock_func.call_count, calls_upper_bound) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@geigerj This is something I would like a second opinion on -- what I am doing here is raising the
RetryErrorwith the given message (which now uses the exception's message), but maintaining the original traceback. I assert this is more useful for developers, and reduces the need to make people care about.cause.exception.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lukesneeringer can I see an example traceback with and without this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. I will need a few minutes to piece together getting one from a live API, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jonparrott Heading to a meeting, so this will be delayed. I have not forgotten it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1. Ideally it would also be possible to determine that the wrapped exception originated at this line of code.