696 lines
26 KiB
Python
696 lines
26 KiB
Python
from tests import unittest
|
|
|
|
import mock
|
|
from nose.tools import assert_equal, assert_is_instance
|
|
|
|
from botocore.retries import standard
|
|
from botocore.retries import quota
|
|
from botocore import model
|
|
from botocore.awsrequest import AWSResponse
|
|
from botocore.exceptions import HTTPClientError, ConnectionError
|
|
from botocore.exceptions import ReadTimeoutError
|
|
|
|
|
|
RETRYABLE_THROTTLED_RESPONSES = [
|
|
# From the spec under "Throttling Errors"
|
|
# The status codes technically don't matter here, but we're adding
|
|
# them for completeness.
|
|
|
|
# StatusCode, ErrorCode, Retryable?
|
|
(400, 'Throttling', True),
|
|
(400, 'ThrottlingException', True),
|
|
(400, 'ThrottledException', True),
|
|
(400, 'RequestThrottledException', True),
|
|
(400, 'TooManyRequestsException', True),
|
|
(400, 'ProvisionedThroughputExceededException', True),
|
|
(503, 'RequestLimitExceeded', True),
|
|
(509, 'BandwidthLimitExceeded', True),
|
|
(400, 'LimitExceededException', True),
|
|
(403, 'RequestThrottled', True),
|
|
(503, 'SlowDown', True),
|
|
(503, 'SlowDown', True),
|
|
(400, 'PriorRequestNotComplete', True),
|
|
(502, 'EC2ThrottledException', True),
|
|
|
|
# These are some negative test cases, not in the spec but we'll use
|
|
# to verify we can detect throttled errors correctly.
|
|
(400, 'NotAThrottlingError', False),
|
|
(500, 'InternalServerError', False),
|
|
# "None" here represents no parsed response we just have a plain
|
|
# HTTP response and a 400 status code response.
|
|
(400, None, False),
|
|
(500, None, False),
|
|
(200, None, False),
|
|
]
|
|
|
|
RETRYABLE_TRANSIENT_ERRORS = [
|
|
# StatusCode, Error, Retryable?
|
|
(400, 'RequestTimeout', True),
|
|
(400, 'RequestTimeoutException', True),
|
|
(400, 'PriorRequestNotComplete', True),
|
|
|
|
# "Any HTTP response with an HTTP status code of 500, 502, 503, or 504".
|
|
(500, None, True),
|
|
(502, None, True),
|
|
(503, None, True),
|
|
(504, None, True),
|
|
# We'll also add a few errors with an explicit error code to verify
|
|
# that the code doesn't matter.
|
|
(500, 'InternalServiceError', True),
|
|
(502, 'BadError', True),
|
|
# These are botocore specific errors that correspond to
|
|
# "Any IO (socket) level error where we are unable to read an HTTP
|
|
# response.
|
|
(None, ConnectionError(error='unknown'), True),
|
|
(None, HTTPClientError(error='unknown'), True),
|
|
|
|
# Negative cases
|
|
(200, None, False),
|
|
# This is a throttling error not a transient error
|
|
(400, 'Throttling', False),
|
|
(400, None, False),
|
|
]
|
|
|
|
|
|
# These tests are intended to be paired with the
|
|
# SERVICE_DESCRIPTION_WITH_RETRIES definition.
|
|
RETRYABLE_MODELED_ERRORS = [
|
|
(400, 'ModeledThrottlingError', True),
|
|
(400, 'ModeledRetryableError', True),
|
|
# Note this is ErrorCodeRetryable, not ModeledRetryableErrorWithCode,
|
|
# because the shape has a error code defined for it.
|
|
(400, 'ErrorCodeRetryable', True),
|
|
(400, 'NonRetryableError', False),
|
|
(None, ConnectionError(error='unknown'), False),
|
|
]
|
|
|
|
|
|
SERVICE_DESCRIPTION_WITH_RETRIES = {
|
|
'metadata': {},
|
|
'operations': {
|
|
'TestOperation': {
|
|
'name': 'TestOperation',
|
|
'input': {'shape': 'FakeInputOutputShape'},
|
|
'output': {'shape': 'FakeInputOutputShape'},
|
|
'errors': [
|
|
{'shape': 'ModeledThrottlingError'},
|
|
{'shape': 'ModeledRetryableError'},
|
|
{'shape': 'ModeledRetryableErrorWithCode'},
|
|
{'shape': 'NonRetryableError'},
|
|
]
|
|
}
|
|
},
|
|
'shapes': {
|
|
'FakeInputOutputShape': {
|
|
'type': 'structure',
|
|
'members': {},
|
|
},
|
|
'ModeledThrottlingError': {
|
|
'type': 'structure',
|
|
'members': {
|
|
'message': {
|
|
'shape': 'ErrorMessage',
|
|
}
|
|
},
|
|
'exception': True,
|
|
'retryable': {'throttling': True}
|
|
},
|
|
'ModeledRetryableError': {
|
|
'type': 'structure',
|
|
'members': {
|
|
'message': {
|
|
'shape': 'ErrorMessage',
|
|
}
|
|
},
|
|
'exception': True,
|
|
'retryable': {}
|
|
},
|
|
'ModeledRetryableErrorWithCode': {
|
|
'type': 'structure',
|
|
'members': {
|
|
'message': {
|
|
'shape': 'ErrorMessage',
|
|
}
|
|
},
|
|
'error': {
|
|
'code': 'ErrorCodeRetryable',
|
|
},
|
|
'exception': True,
|
|
'retryable': {'throttling': True}
|
|
},
|
|
'NonRetryableError': {
|
|
'type': 'structure',
|
|
'members': {
|
|
'message': {
|
|
'shape': 'ErrorMessage',
|
|
}
|
|
},
|
|
'exception': True,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_can_detect_retryable_transient_errors():
|
|
transient_checker = standard.TransientRetryableChecker()
|
|
for case in RETRYABLE_TRANSIENT_ERRORS:
|
|
yield (_verify_retryable, transient_checker, None) + case
|
|
|
|
|
|
def test_can_detect_retryable_throttled_errors():
|
|
throttled_checker = standard.ThrottledRetryableChecker()
|
|
for case in RETRYABLE_THROTTLED_RESPONSES:
|
|
yield (_verify_retryable, throttled_checker, None) + case
|
|
|
|
|
|
def test_can_detect_modeled_retryable_errors():
|
|
modeled_retry_checker = standard.ModeledRetryableChecker()
|
|
test_params = (_verify_retryable, modeled_retry_checker,
|
|
get_operation_model_with_retries())
|
|
for case in RETRYABLE_MODELED_ERRORS:
|
|
test_case = test_params + case
|
|
yield test_case
|
|
|
|
|
|
def test_standard_retry_conditions():
|
|
# This is verifying that the high level object used for checking
|
|
# retry conditions still handles all the individual testcases.
|
|
standard_checker = standard.StandardRetryConditions()
|
|
op_model = get_operation_model_with_retries()
|
|
all_cases = (
|
|
RETRYABLE_TRANSIENT_ERRORS + RETRYABLE_THROTTLED_RESPONSES +
|
|
RETRYABLE_MODELED_ERRORS)
|
|
# It's possible that cases that are retryable for an individual checker
|
|
# are retryable for a different checker. We need to filter out all
|
|
# the False cases.
|
|
all_cases = [c for c in all_cases if c[2]]
|
|
test_params = (_verify_retryable, standard_checker, op_model)
|
|
for case in all_cases:
|
|
yield test_params + case
|
|
|
|
|
|
def get_operation_model_with_retries():
|
|
service = model.ServiceModel(SERVICE_DESCRIPTION_WITH_RETRIES,
|
|
service_name='my-service')
|
|
return service.operation_model('TestOperation')
|
|
|
|
|
|
def _verify_retryable(checker, operation_model,
|
|
status_code, error, is_retryable):
|
|
http_response = AWSResponse(status_code=status_code,
|
|
raw=None, headers={}, url='https://foo/')
|
|
parsed_response = None
|
|
caught_exception = None
|
|
if error is not None:
|
|
if isinstance(error, Exception):
|
|
caught_exception = error
|
|
else:
|
|
parsed_response = {'Error': {'Code': error, 'Message': 'Error'}}
|
|
context = standard.RetryContext(
|
|
attempt_number=1,
|
|
operation_model=operation_model,
|
|
parsed_response=parsed_response,
|
|
http_response=http_response,
|
|
caught_exception=caught_exception,
|
|
)
|
|
assert_equal(checker.is_retryable(context), is_retryable)
|
|
|
|
|
|
def arbitrary_retry_context():
|
|
# Used when you just need a dummy retry context that looks like
|
|
# a failed request.
|
|
return standard.RetryContext(
|
|
attempt_number=1,
|
|
operation_model=None,
|
|
parsed_response={'Error': {'Code': 'ErrorCode', 'Message': 'message'}},
|
|
http_response=AWSResponse(status_code=500,
|
|
raw=None, headers={}, url='https://foo'),
|
|
caught_exception=None,
|
|
)
|
|
|
|
|
|
def test_can_honor_max_attempts():
|
|
checker = standard.MaxAttemptsChecker(max_attempts=3)
|
|
context = arbitrary_retry_context()
|
|
context.attempt_number = 1
|
|
assert_equal(checker.is_retryable(context), True)
|
|
|
|
context.attempt_number = 2
|
|
assert_equal(checker.is_retryable(context), True)
|
|
|
|
context.attempt_number = 3
|
|
assert_equal(checker.is_retryable(context), False)
|
|
|
|
|
|
def test_max_attempts_adds_metadata_key_when_reached():
|
|
checker = standard.MaxAttemptsChecker(max_attempts=3)
|
|
context = arbitrary_retry_context()
|
|
context.attempt_number = 3
|
|
assert_equal(checker.is_retryable(context), False)
|
|
assert_equal(context.get_retry_metadata(), {'MaxAttemptsReached': True})
|
|
|
|
|
|
def test_can_create_default_retry_handler():
|
|
mock_client = mock.Mock()
|
|
mock_client.meta.service_model.service_id = model.ServiceId('my-service')
|
|
assert_is_instance(standard.register_retry_handler(mock_client),
|
|
standard.RetryHandler)
|
|
call_args_list = mock_client.meta.events.register.call_args_list
|
|
# We should have registered the retry quota to after-calls
|
|
first_call = call_args_list[0][0]
|
|
second_call = call_args_list[1][0]
|
|
# Not sure if there's a way to verify the class associated with the
|
|
# bound method matches what we expect.
|
|
assert_equal(first_call[0], 'after-call.my-service')
|
|
assert_equal(second_call[0], 'needs-retry.my-service')
|
|
|
|
|
|
class TestRetryHandler(unittest.TestCase):
|
|
def setUp(self):
|
|
self.retry_policy = mock.Mock(spec=standard.RetryPolicy)
|
|
self.retry_event_adapter = mock.Mock(spec=standard.RetryEventAdapter)
|
|
self.retry_quota = mock.Mock(spec=standard.RetryQuotaChecker)
|
|
self.retry_handler = standard.RetryHandler(
|
|
retry_policy=self.retry_policy,
|
|
retry_event_adapter=self.retry_event_adapter,
|
|
retry_quota=self.retry_quota
|
|
)
|
|
|
|
def test_does_need_retry(self):
|
|
self.retry_event_adapter.create_retry_context.return_value = \
|
|
mock.sentinel.retry_context
|
|
self.retry_policy.should_retry.return_value = True
|
|
self.retry_quota.acquire_retry_quota.return_value = True
|
|
self.retry_policy.compute_retry_delay.return_value = 1
|
|
|
|
self.assertEqual(
|
|
self.retry_handler.needs_retry(fake_kwargs='foo'), 1)
|
|
self.retry_event_adapter.create_retry_context.assert_called_with(
|
|
fake_kwargs='foo')
|
|
self.retry_policy.should_retry.assert_called_with(
|
|
mock.sentinel.retry_context)
|
|
self.retry_quota.acquire_retry_quota.assert_called_with(
|
|
mock.sentinel.retry_context)
|
|
self.retry_policy.compute_retry_delay.assert_called_with(
|
|
mock.sentinel.retry_context)
|
|
|
|
def test_does_not_need_retry(self):
|
|
self.retry_event_adapter.create_retry_context.return_value = \
|
|
mock.sentinel.retry_context
|
|
self.retry_policy.should_retry.return_value = False
|
|
|
|
self.assertIsNone(self.retry_handler.needs_retry(fake_kwargs='foo'))
|
|
# Shouldn't consult quota if we don't have a retryable condition.
|
|
self.assertFalse(self.retry_quota.acquire_retry_quota.called)
|
|
|
|
def test_needs_retry_but_not_enough_quota(self):
|
|
self.retry_event_adapter.create_retry_context.return_value = \
|
|
mock.sentinel.retry_context
|
|
self.retry_policy.should_retry.return_value = True
|
|
self.retry_quota.acquire_retry_quota.return_value = False
|
|
|
|
self.assertIsNone(self.retry_handler.needs_retry(fake_kwargs='foo'))
|
|
|
|
def test_retry_handler_adds_retry_metadata_to_response(self):
|
|
self.retry_event_adapter.create_retry_context.return_value = \
|
|
mock.sentinel.retry_context
|
|
self.retry_policy.should_retry.return_value = False
|
|
self.assertIsNone(self.retry_handler.needs_retry(fake_kwargs='foo'))
|
|
adapter = self.retry_event_adapter
|
|
adapter.adapt_retry_response_from_context.assert_called_with(
|
|
mock.sentinel.retry_context)
|
|
|
|
|
|
class TestRetryEventAdapter(unittest.TestCase):
|
|
def setUp(self):
|
|
self.success_response = {'ResponseMetadata': {}, 'Foo': {}}
|
|
self.failed_response = {'ResponseMetadata': {}, 'Error': {}}
|
|
self.http_success = AWSResponse(
|
|
status_code=200, raw=None, headers={}, url='https://foo/')
|
|
self.http_failed = AWSResponse(
|
|
status_code=500, raw=None, headers={}, url='https://foo/')
|
|
self.caught_exception = ConnectionError(error='unknown')
|
|
|
|
def test_create_context_from_success_response(self):
|
|
context = standard.RetryEventAdapter().create_retry_context(
|
|
response=(self.http_success, self.success_response),
|
|
attempts=1,
|
|
caught_exception=None,
|
|
request_dict={'context': {'foo': 'bar'}},
|
|
operation=mock.sentinel.operation_model,
|
|
)
|
|
|
|
self.assertEqual(context.attempt_number, 1)
|
|
self.assertEqual(context.operation_model,
|
|
mock.sentinel.operation_model)
|
|
self.assertEqual(context.parsed_response, self.success_response)
|
|
self.assertEqual(context.http_response, self.http_success)
|
|
self.assertEqual(context.caught_exception, None)
|
|
self.assertEqual(context.request_context, {'foo': 'bar'})
|
|
|
|
def test_create_context_from_service_error(self):
|
|
context = standard.RetryEventAdapter().create_retry_context(
|
|
response=(self.http_failed, self.failed_response),
|
|
attempts=1,
|
|
caught_exception=None,
|
|
request_dict={'context': {'foo': 'bar'}},
|
|
operation=mock.sentinel.operation_model,
|
|
)
|
|
# We already tested the other attributes in
|
|
# test_create_context_from_success_response so we're only checking
|
|
# the attributes relevant to this test.
|
|
self.assertEqual(context.parsed_response, self.failed_response)
|
|
self.assertEqual(context.http_response, self.http_failed)
|
|
|
|
def test_create_context_from_exception(self):
|
|
context = standard.RetryEventAdapter().create_retry_context(
|
|
response=None,
|
|
attempts=1,
|
|
caught_exception=self.caught_exception,
|
|
request_dict={'context': {'foo': 'bar'}},
|
|
operation=mock.sentinel.operation_model,
|
|
)
|
|
self.assertEqual(context.parsed_response, None)
|
|
self.assertEqual(context.http_response, None)
|
|
self.assertEqual(context.caught_exception, self.caught_exception)
|
|
|
|
def test_can_inject_metadata_back_to_context(self):
|
|
adapter = standard.RetryEventAdapter()
|
|
context = adapter.create_retry_context(
|
|
attempts=1,
|
|
operation=None,
|
|
caught_exception=None,
|
|
request_dict={'context': {}},
|
|
response=(self.http_failed, self.failed_response)
|
|
)
|
|
context.add_retry_metadata(MaxAttemptsReached=True)
|
|
adapter.adapt_retry_response_from_context(context)
|
|
self.assertEqual(
|
|
self.failed_response['ResponseMetadata']['MaxAttemptsReached'],
|
|
True
|
|
)
|
|
|
|
|
|
class TestRetryPolicy(unittest.TestCase):
|
|
def setUp(self):
|
|
self.retry_checker = mock.Mock(spec=standard.StandardRetryConditions)
|
|
self.retry_backoff = mock.Mock(spec=standard.ExponentialBackoff)
|
|
self.retry_policy = standard.RetryPolicy(
|
|
retry_checker=self.retry_checker,
|
|
retry_backoff=self.retry_backoff)
|
|
|
|
def test_delegates_to_retry_checker(self):
|
|
self.retry_checker.is_retryable.return_value = True
|
|
self.assertTrue(self.retry_policy.should_retry(mock.sentinel.context))
|
|
self.retry_checker.is_retryable.assert_called_with(
|
|
mock.sentinel.context)
|
|
|
|
def test_delegates_to_retry_backoff(self):
|
|
self.retry_backoff.delay_amount.return_value = 1
|
|
self.assertEqual(
|
|
self.retry_policy.compute_retry_delay(mock.sentinel.context), 1)
|
|
self.retry_backoff.delay_amount.assert_called_with(
|
|
mock.sentinel.context)
|
|
|
|
|
|
class TestExponentialBackoff(unittest.TestCase):
|
|
def setUp(self):
|
|
self.random = lambda: 1
|
|
self.backoff = standard.ExponentialBackoff(max_backoff=20,
|
|
random=self.random)
|
|
|
|
def test_range_of_exponential_backoff(self):
|
|
backoffs = [
|
|
self.backoff.delay_amount(standard.RetryContext(attempt_number=i))
|
|
for i in range(1, 10)
|
|
]
|
|
# Note that we're capped at 20 which is our max backoff.
|
|
self.assertEqual(backoffs,
|
|
[1, 2, 4, 8, 16, 20, 20, 20, 20])
|
|
|
|
def test_exponential_backoff_with_jitter(self):
|
|
backoff = standard.ExponentialBackoff()
|
|
backoffs = [
|
|
backoff.delay_amount(standard.RetryContext(attempt_number=3))
|
|
for i in range(10)
|
|
]
|
|
# For attempt number 3, we should have a max value of 4 (2 ^ 2),
|
|
# so we can assert all the backoff values are within that range.
|
|
for x in backoffs:
|
|
self.assertTrue(0 <= x <= 4)
|
|
|
|
|
|
class TestRetryQuotaChecker(unittest.TestCase):
|
|
def setUp(self):
|
|
self.quota = quota.RetryQuota(500)
|
|
self.quota_checker = standard.RetryQuotaChecker(self.quota)
|
|
self.request_context = {}
|
|
|
|
def create_context(self, is_timeout_error=False, status_code=200):
|
|
caught_exception = None
|
|
if is_timeout_error:
|
|
caught_exception = ReadTimeoutError(endpoint_url='https://foo')
|
|
http_response = AWSResponse(status_code=status_code, raw=None,
|
|
headers={}, url='https://foo/')
|
|
context = standard.RetryContext(
|
|
attempt_number=1,
|
|
request_context=self.request_context,
|
|
caught_exception=caught_exception,
|
|
http_response=http_response,
|
|
)
|
|
return context
|
|
|
|
def test_can_acquire_quota_non_timeout_error(self):
|
|
self.assertTrue(
|
|
self.quota_checker.acquire_retry_quota(self.create_context())
|
|
)
|
|
self.assertEqual(self.request_context['retry_quota_capacity'], 5)
|
|
|
|
def test_can_acquire_quota_for_timeout_error(self):
|
|
self.assertTrue(
|
|
self.quota_checker.acquire_retry_quota(
|
|
self.create_context(is_timeout_error=True))
|
|
)
|
|
self.assertEqual(self.request_context['retry_quota_capacity'], 10)
|
|
|
|
def test_can_release_quota_based_on_context_value_on_success(self):
|
|
context = self.create_context()
|
|
# This is where we had to retry the request but eventually
|
|
# succeeded.
|
|
http_response = self.create_context(status_code=200).http_response
|
|
self.assertTrue(
|
|
self.quota_checker.acquire_retry_quota(context)
|
|
)
|
|
self.assertEqual(self.quota.available_capacity, 495)
|
|
self.quota_checker.release_retry_quota(context.request_context,
|
|
http_response=http_response)
|
|
self.assertEqual(self.quota.available_capacity, 500)
|
|
|
|
def test_dont_release_quota_if_all_retries_failed(self):
|
|
context = self.create_context()
|
|
# If max_attempts_reached is True, then it means we used up all
|
|
# our retry attempts and still failed. In this case we shouldn't
|
|
# give any retry quota back.
|
|
http_response = self.create_context(status_code=500).http_response
|
|
self.assertTrue(
|
|
self.quota_checker.acquire_retry_quota(context)
|
|
)
|
|
self.assertEqual(self.quota.available_capacity, 495)
|
|
self.quota_checker.release_retry_quota(context.request_context,
|
|
http_response=http_response)
|
|
self.assertEqual(self.quota.available_capacity, 495)
|
|
|
|
def test_can_release_default_quota_if_not_in_context(self):
|
|
context = self.create_context()
|
|
self.assertTrue(
|
|
self.quota_checker.acquire_retry_quota(context)
|
|
)
|
|
self.assertEqual(self.quota.available_capacity, 495)
|
|
# We're going to remove the quota amount from the request context.
|
|
# This represents a successful request with no retries.
|
|
self.request_context.pop('retry_quota_capacity')
|
|
self.quota_checker.release_retry_quota(context.request_context,
|
|
context.http_response)
|
|
# We expect only 1 unit was released.
|
|
self.assertEqual(self.quota.available_capacity, 496)
|
|
|
|
def test_acquire_quota_fails(self):
|
|
quota_checker = standard.RetryQuotaChecker(
|
|
quota.RetryQuota(initial_capacity=5))
|
|
# The first one succeeds.
|
|
self.assertTrue(
|
|
quota_checker.acquire_retry_quota(self.create_context())
|
|
)
|
|
# But we should fail now because we're out of quota.
|
|
self.request_context.pop('retry_quota_capacity')
|
|
self.assertFalse(
|
|
quota_checker.acquire_retry_quota(self.create_context())
|
|
)
|
|
self.assertNotIn('retry_quota_capacity', self.request_context)
|
|
|
|
def test_quota_reached_adds_retry_metadata(self):
|
|
quota_checker = standard.RetryQuotaChecker(
|
|
quota.RetryQuota(initial_capacity=0))
|
|
context = self.create_context()
|
|
self.assertFalse(quota_checker.acquire_retry_quota(context))
|
|
self.assertEqual(
|
|
context.get_retry_metadata(),
|
|
{'RetryQuotaReached': True}
|
|
)
|
|
|
|
def test_single_failed_request_does_not_give_back_quota(self):
|
|
context = self.create_context()
|
|
http_response = self.create_context(status_code=400).http_response
|
|
# First deduct some amount of the retry quota so we're not hitting
|
|
# the upper bound.
|
|
self.quota.acquire(50)
|
|
self.assertEqual(self.quota.available_capacity, 450)
|
|
self.quota_checker.release_retry_quota(context.request_context,
|
|
http_response=http_response)
|
|
self.assertEqual(self.quota.available_capacity, 450)
|
|
|
|
|
|
class TestRetryContext(unittest.TestCase):
|
|
def test_can_get_error_code(self):
|
|
context = arbitrary_retry_context()
|
|
context.parsed_response['Error']['Code'] = 'MyErrorCode'
|
|
self.assertEqual(context.get_error_code(), 'MyErrorCode')
|
|
|
|
def test_no_error_code_if_no_parsed_response(self):
|
|
context = arbitrary_retry_context()
|
|
context.parsed_response = None
|
|
self.assertIsNone(context.get_error_code())
|
|
|
|
def test_no_error_code_returns_none(self):
|
|
context = arbitrary_retry_context()
|
|
context.parsed_response = {}
|
|
self.assertIsNone(context.get_error_code())
|
|
|
|
def test_can_add_retry_reason(self):
|
|
context = arbitrary_retry_context()
|
|
context.add_retry_metadata(MaxAttemptsReached=True)
|
|
self.assertEqual(context.get_retry_metadata(),
|
|
{'MaxAttemptsReached': True})
|
|
|
|
def test_handles_non_error_top_level_error_key_get_error_code(self):
|
|
response = AWSResponse(
|
|
status_code=200,
|
|
raw=None,
|
|
headers={},
|
|
url='https://foo',
|
|
)
|
|
# A normal response can have a top level "Error" key that doesn't map
|
|
# to an error code and should be ignored
|
|
context = standard.RetryContext(
|
|
attempt_number=1,
|
|
operation_model=None,
|
|
parsed_response={'Error': 'This is a 200 response body'},
|
|
http_response=response,
|
|
caught_exception=None,
|
|
)
|
|
self.assertEqual(context.get_error_code(), None)
|
|
|
|
|
|
class TestThrottlingErrorDetector(unittest.TestCase):
|
|
def setUp(self):
|
|
self.throttling_detector = standard.ThrottlingErrorDetector(
|
|
standard.RetryEventAdapter())
|
|
|
|
def create_needs_retry_kwargs(self, **kwargs):
|
|
retry_kwargs = {
|
|
'response': None,
|
|
'attempts': 1,
|
|
'operation': None,
|
|
'caught_exception': None,
|
|
'request_dict': {'context': {}},
|
|
}
|
|
retry_kwargs.update(kwargs)
|
|
return retry_kwargs
|
|
|
|
def test_can_check_error_from_code(self):
|
|
kwargs = self.create_needs_retry_kwargs()
|
|
kwargs['response'] = (None, {'Error': {'Code': 'ThrottledException'}})
|
|
self.assertTrue(
|
|
self.throttling_detector.is_throttling_error(**kwargs)
|
|
)
|
|
|
|
def test_no_throttling_error(self):
|
|
kwargs = self.create_needs_retry_kwargs()
|
|
kwargs['response'] = (None, {'Error': {'Code': 'RandomError'}})
|
|
self.assertFalse(
|
|
self.throttling_detector.is_throttling_error(**kwargs)
|
|
)
|
|
|
|
def test_detects_modeled_errors(self):
|
|
kwargs = self.create_needs_retry_kwargs()
|
|
kwargs['response'] = (
|
|
None, {'Error': {'Code': 'ModeledThrottlingError'}}
|
|
)
|
|
kwargs['operation'] = get_operation_model_with_retries()
|
|
self.assertTrue(
|
|
self.throttling_detector.is_throttling_error(**kwargs)
|
|
)
|
|
|
|
|
|
class TestModeledRetryErrorDetector(unittest.TestCase):
|
|
def setUp(self):
|
|
self.modeled_error = standard.ModeledRetryErrorDetector()
|
|
|
|
def test_not_retryable(self):
|
|
context = arbitrary_retry_context()
|
|
self.assertIsNone(self.modeled_error.detect_error_type(context))
|
|
|
|
def test_transient_error(self):
|
|
context = arbitrary_retry_context()
|
|
context.parsed_response['Error']['Code'] = 'ModeledRetryableError'
|
|
context.operation_model = get_operation_model_with_retries()
|
|
self.assertEqual(
|
|
self.modeled_error.detect_error_type(context),
|
|
self.modeled_error.TRANSIENT_ERROR
|
|
)
|
|
|
|
def test_throttling_error(self):
|
|
context = arbitrary_retry_context()
|
|
context.parsed_response['Error']['Code'] = 'ModeledThrottlingError'
|
|
context.operation_model = get_operation_model_with_retries()
|
|
self.assertEqual(
|
|
self.modeled_error.detect_error_type(context),
|
|
self.modeled_error.THROTTLING_ERROR
|
|
)
|
|
|
|
|
|
class Yes(standard.BaseRetryableChecker):
|
|
def is_retryable(self, context):
|
|
return True
|
|
|
|
|
|
class No(standard.BaseRetryableChecker):
|
|
def is_retryable(self, context):
|
|
return False
|
|
|
|
|
|
class TestOrRetryChecker(unittest.TestCase):
|
|
def test_can_match_any_checker(self):
|
|
self.assertTrue(
|
|
standard.OrRetryChecker(
|
|
[Yes(), No()]
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
standard.OrRetryChecker(
|
|
[No(), Yes()]
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
standard.OrRetryChecker(
|
|
[Yes(), Yes()]
|
|
)
|
|
)
|
|
|
|
def test_false_if_no_checkers_match(self):
|
|
self.assertTrue(
|
|
standard.OrRetryChecker(
|
|
[No(), No(), No()]
|
|
)
|
|
)
|